mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-11 02:51:10 +00:00
Windows components: picked automatically by each game's source (#554)
Each game's Windows components now come from the list its own source trusts, and the ones that list names turn on by themselves. The user's own on/off switches always win; each automatic row says why it is on. - Steam games: the Steamworks Shared redistributables Steam installs with the game (appmanifest SharedDepots, else the client's appinfo.vdf). Steam keeps its install scripts marked as already run. - GOG: the build manifest's dependencies. Epic: the manifest's prerequisite (Unreal's maps to VC++ and DirectX). Amazon: fuel.json PostInstall. Older GOG/Epic installs get their list fetched once with the saved sign-in; game files are never downloaded again. - Custom games (the + button, Add all, migrated folders): one shared Steam match per game, also used for artwork. An appid from the game's files turns components on; a name-only match stays Recommended; no match falls back to the folder scan. - Steam app info for apps the client never saw comes from an anonymous Steam PICS lookup (CM websocket, anonymous logon, product info), cached per appid; the user's Steam sign-in is never used. - Missing components download in the background on one low-priority worker that pauses during sessions and waits on battery saver, heat or a metered network unless the user asked. A game Steam installs during a session is readied right away so it can be played without ending the session; the launch waits up to 10 s only for a component still on its way. - Page: "On for this game" with reasons, a quiet "Checking what this game needs..." only for games never worked out, rows that ease in, pieces that need a download greyed out while offline. The intro names no other app. - The folder scan skips Unity's *_Data/Managed (Mono, not .NET Framework).
This commit is contained in:
1 parent
d45933e559
commit
a2e596a200
35 files changed
+3810
-146
No files matched your search
@@ -366,6 +366,8 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
private var romsDir by mutableStateOf<String?>(null)
|
||||
private var steamGames by mutableStateOf<List<Library.SteamGame>>(emptyList())
|
||||
@Volatile private var componentSweepDone = false
|
||||
@Volatile private var componentSweepSession: String? = null
|
||||
private var emulatorList by mutableStateOf<List<Library.Emulator>>(emptyList())
|
||||
private var runningLabel by mutableStateOf<String?>(null)
|
||||
private var logsEnabled by mutableStateOf(true)
|
||||
@@ -616,7 +618,15 @@ class MainActivity : ComponentActivity() {
|
||||
.putExtra(SessionService.EXTRA_STEAM_UI, "desktop"), steamSession = true)
|
||||
},
|
||||
// A store game may need something done first (an Epic game its sign-in code).
|
||||
onSteamGame = { g -> if (shortcutPicker) chooseGameShortcut(g) else com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) { launchGame(g) } },
|
||||
onSteamGame = { g ->
|
||||
if (shortcutPicker) chooseGameShortcut(g)
|
||||
else {
|
||||
// The launch takes the Windows components already on; the game's list is
|
||||
// looked at again in the background once the session is over.
|
||||
com.droiddeck.launcher.frontend.AutoComponents.beforeLaunch(this, g)
|
||||
com.droiddeck.launcher.stores.StoreLaunch.prepare(this, g) { launchGame(g) }
|
||||
}
|
||||
},
|
||||
onGameShortcut = { g -> com.droiddeck.launcher.frontend.GameShortcuts.pin(this, g) },
|
||||
onExportGameFile = { g -> pickGameExport(g) },
|
||||
onSyncGameFiles = { pickGameExport(null) },
|
||||
@@ -1447,6 +1457,8 @@ class MainActivity : ComponentActivity() {
|
||||
val fresh = folders.mapNotNull { f ->
|
||||
com.droiddeck.launcher.frontend.AddedGames.single(this, f)?.let { com.droiddeck.launcher.frontend.Library.addedGame(this, it) }
|
||||
}
|
||||
// An added or edited game's Windows components, from its source's list.
|
||||
fresh.forEach { com.droiddeck.launcher.frontend.AutoComponents.refreshLater(this, it) }
|
||||
ui.post {
|
||||
val paths = folders.toSet()
|
||||
steamGames = (steamGames.filterNot { it.library == Library.ADDED && it.gameFiles?.path in paths } + fresh).distinctBy { it.gameId }
|
||||
@@ -1608,13 +1620,39 @@ class MainActivity : ComponentActivity() {
|
||||
val scanGeneration = ++libraryScanGeneration
|
||||
if (shortcutPicker) shortcutLibraryScanning = true
|
||||
Thread({
|
||||
// One update with the whole list: the wall places games by their position in it, so a
|
||||
// partial list first would shuffle every capsule when the rest arrived. LibraryCache
|
||||
// covers the wait.
|
||||
// Steam's own games first, from their manifests alone (quick), beside the added games
|
||||
// already listed: a game Steam installed during the session shows as the session ends,
|
||||
// without waiting for the added games' walk below. Its Windows components' list is
|
||||
// queued then too, on the low-priority worker, so its page is usually ready.
|
||||
if (ready) {
|
||||
val quick = runCatching { Library.steamGames(this) }.getOrNull()
|
||||
if (quick != null) ui.post {
|
||||
if (scanGeneration == libraryScanGeneration && steamGames.isNotEmpty()) {
|
||||
val before = steamGames.map { it.gameId }.toSet()
|
||||
val fresh = quick.filter { it.gameId !in before }
|
||||
if (fresh.isNotEmpty()) {
|
||||
steamGames = (quick + steamGames.filter { it.library == Library.ADDED }).distinctBy { it.gameId }
|
||||
fresh.forEach { com.droiddeck.launcher.frontend.AutoComponents.queueGame(this, it, asked = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Then one update with the whole list: the wall places games by their position in it,
|
||||
// so a partial list first would shuffle every capsule when the rest arrived.
|
||||
// LibraryCache covers the wait.
|
||||
val games = if (ready) Library.launchableGames(this) else emptyList()
|
||||
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
|
||||
val all = games.distinctBy { it.gameId }
|
||||
if (ready) com.droiddeck.launcher.frontend.LibraryCache.save(this, all)
|
||||
// Once per app start: the Steam games' Windows components from Steam's lists.
|
||||
// Queued only, after the launcher has been up a little; the worker takes them one at a time.
|
||||
// And again after each session (a game installed in it, files changed).
|
||||
val endedSession = SessionState.sessionId.takeIf { !SessionState.running && it != null && it != componentSweepSession }
|
||||
if (ready && (!componentSweepDone || endedSession != null)) {
|
||||
componentSweepDone = true
|
||||
endedSession?.let { componentSweepSession = it }
|
||||
ui.postDelayed({ Thread({ com.droiddeck.launcher.frontend.AutoComponents.sweep(this, all) }, "components-sweep").start() }, 10_000)
|
||||
}
|
||||
ui.post {
|
||||
if (scanGeneration == libraryScanGeneration) {
|
||||
steamGames = all
|
||||
|
||||
@@ -137,8 +137,7 @@ object AddedGameArt {
|
||||
val out = ArrayList<Option>()
|
||||
folderImages(game.folder).forEach { out.add(Option(FOLDER, it.path, it.path)) }
|
||||
val cache = cacheDir(context, game)
|
||||
val steamId = runCatching { GameIdentifier.identify(game.exe).appId?.toString() }.getOrNull()
|
||||
?: File(cache, "steam-appid").takeIf { it.isFile }?.readText()?.trim()?.takeIf { it.isNotEmpty() && it != "none" }
|
||||
val steamId = SteamMatch.resolve(context, game.folder.path, game.exe, game.name, legacy = File(cache, "steam-appid")).appId?.toString()
|
||||
val remote = when (slot) { Slot.COVER -> "library_600x900.jpg"; Slot.BACKGROUND -> "library_hero.jpg"; Slot.LOGO -> "logo.png"; Slot.ICON -> null }
|
||||
if (steamId != null && remote != null) {
|
||||
val url = "$CDN$steamId/$remote".takeIf { exists(it) } ?: steamAssetUrl(steamId, remote)
|
||||
@@ -222,10 +221,11 @@ object AddedGameArt {
|
||||
val cache = cacheDir(context, game)
|
||||
if (File(cache, "p.jpg").isFile) continue
|
||||
cache.mkdirs()
|
||||
val appId = runCatching { GameIdentifier.identify(game.exe).appId }.getOrNull()
|
||||
val id = steamAppId(game.name, appId, File(cache, "steam-appid")) { name ->
|
||||
runCatching { search(name) }.onFailure { Log.w(TAG, "${game.name}: store search failed (${it.message})") }.getOrNull()
|
||||
val match = steamMatch(context, game) { name ->
|
||||
runCatching { search(name) }.onFailure { Log.w(TAG, "${game.name}: store search failed (${it.message})") }.getOrNull()?.toIntOrNull()
|
||||
}
|
||||
val appId = match.appId?.takeIf { match.certainty == SteamMatch.Certainty.FILES }
|
||||
val id = match.appId?.toString()
|
||||
if (id != null && download(id, cache)) changed = true
|
||||
if (File(cache, "p.jpg").isFile || sgdbKey.isEmpty()) continue
|
||||
val gridId = remembered(File(cache, "sgdb-id")) {
|
||||
@@ -237,16 +237,13 @@ object AddedGameArt {
|
||||
}
|
||||
|
||||
/**
|
||||
* The game's Steam appid for its art: the one its files name ([appId]) as it is, else the
|
||||
* remembered or freshly searched one ([search] on [name]); null for none.
|
||||
* The game's Steam app, the one record its Windows components use too ([SteamMatch]): what its
|
||||
* files name, else a store search on its title ([search]) whose result has the same name.
|
||||
*/
|
||||
internal fun steamAppId(name: String, appId: Int?, lookup: File, search: (String) -> String?): String? {
|
||||
if (appId != null) {
|
||||
lookup.writeText(appId.toString())
|
||||
Log.i(TAG, "$name: Steam app $appId (from its files)")
|
||||
return appId.toString()
|
||||
}
|
||||
return remembered(lookup) { search(name)?.also { Log.i(TAG, "$name: Steam app $it") } }
|
||||
internal fun steamMatch(context: Context, game: AddedGames.Game, search: (String) -> Int?): SteamMatch.Match {
|
||||
val match = SteamMatch.resolve(context, game.folder.path, game.exe, game.name, legacy = File(cacheDir(context, game), "steam-appid"), search = search)
|
||||
match.appId?.let { Log.i(TAG, "${game.name}: Steam app $it (${match.certainty.name.lowercase()})") }
|
||||
return match
|
||||
}
|
||||
|
||||
/** What [marker] remembers (an id, or "none" for a week), else [find]'s answer written to it. */
|
||||
|
||||
@@ -0,0 +1,419 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.os.Build
|
||||
import android.os.PowerManager
|
||||
import android.util.AtomicFile
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import com.droiddeck.launcher.session.WinComponents
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.concurrent.PriorityBlockingQueue
|
||||
|
||||
/**
|
||||
* Turns on, per game, the Windows components its source's list names ([WinCompSources]). Only the
|
||||
* findings marked auto are turned on, only the ones that install here, and never one the user
|
||||
* switched off: the user's switches are kept apart ([WinComponents.Selection.user]) and win.
|
||||
*
|
||||
* All the work is done by one background thread (low priority, so are the runtime helpers it
|
||||
* starts), one job at a time from one queue: working out a game's list (kept, so a page opens on
|
||||
* it) and downloading a component (each once, whichever games want it). The queue waits while a
|
||||
* session runs; jobs nobody asked for (the start-up sweep, a game being launched) also wait on
|
||||
* battery saver, a warm device or a metered network. Nothing here runs on the caller's thread.
|
||||
*/
|
||||
object AutoComponents {
|
||||
private const val TAG = "AutoComponents"
|
||||
|
||||
/** The key a game's picks are kept under: the appid its prefix is named after. */
|
||||
fun appKey(game: Library.SteamGame): String? =
|
||||
if (game.library == Library.ADDED) Integer.toUnsignedString(game.appId) else game.appId.takeIf { it > 0 }?.toString()
|
||||
|
||||
/**
|
||||
* What turns on by itself for [findings], given [catalog] and what is [installed]: the catalog
|
||||
* name each auto finding installs as, with its reason. A component that needs a Windows
|
||||
* installer, or is not in the catalog, stays out (the editor lists it as a suggestion).
|
||||
*/
|
||||
fun autoPicks(findings: List<WinCompSources.Finding>, catalog: Map<String, WinComponents.Component>): Map<String, WinComponents.AutoReason> {
|
||||
val out = LinkedHashMap<String, WinComponents.AutoReason>()
|
||||
for (f in findings) {
|
||||
if (!f.auto || f.component == null) continue
|
||||
val id = WinComponents.installable(f.component, catalog)
|
||||
val c = catalog[id] ?: continue
|
||||
if (WinComponents.support(c, catalog) != WinComponents.Support.READY) continue
|
||||
if (id !in out) out[id] = WinComponents.AutoReason(f.origin.name, listOf(f.original) + f.args)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- seams for tests ----
|
||||
@VisibleForTesting internal var catalogOf: (Context, Boolean) -> List<WinComponents.Component>? =
|
||||
{ c, network -> if (network) WinComponents.fetch(c) else WinComponents.cached(c) }
|
||||
@VisibleForTesting internal var installedOf: (Context) -> Set<String> = { WinComponents.installedIds(it).toSet() }
|
||||
@VisibleForTesting internal var installer: (Context, WinComponents.Component, Map<String, WinComponents.Component>, (WinComponents.Progress) -> Unit) -> String? =
|
||||
{ c, comp, all, progress -> WinComponents.install(c, comp, all, progress) }
|
||||
@VisibleForTesting internal var sessionRunning: () -> Boolean = { SessionState.running }
|
||||
/** Battery saver, a warm device or a metered network: no work nobody asked for. */
|
||||
@VisibleForTesting internal var unfavourable: (Context) -> Boolean = { c -> constrained(c) }
|
||||
/** A usable network: a download waits for one. */
|
||||
@VisibleForTesting internal var online: (Context) -> Boolean = { c -> isOnline(c) }
|
||||
/** Tests run the queue on the caller's thread ([drain]) instead of the worker. */
|
||||
@VisibleForTesting @Volatile internal var runNow = false
|
||||
|
||||
/** Bumped whenever a game's list or automatic components change, so an open page reads them again. */
|
||||
val changes = MutableStateFlow(0)
|
||||
|
||||
/** The components downloading for an automatic pick right now, with their progress. */
|
||||
val downloading = MutableStateFlow<Map<String, WinComponents.Progress>>(emptyMap())
|
||||
|
||||
/** Games (by [appKey]) whose list is queued or being worked out: a page waits on it. */
|
||||
val pendingGames = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
/** Components queued for an automatic pick, not yet downloaded. */
|
||||
val pendingComponents = MutableStateFlow<Set<String>>(emptySet())
|
||||
|
||||
private fun mark(job: Job, on: Boolean) {
|
||||
if (job.pics != null) return
|
||||
val flow = if (job.component != null) pendingComponents else pendingGames
|
||||
val id = job.component ?: job.game?.let { appKey(it) } ?: return
|
||||
flow.value = if (on) flow.value + id else flow.value - id
|
||||
}
|
||||
|
||||
/** One piece of work: a game's list ([game]) or one component's download ([component]). [asked]: the user is waiting on it. */
|
||||
internal class Job(
|
||||
val game: Library.SteamGame?, val component: String?, val asked: Boolean, val seq: Long, val pics: List<Int>? = null,
|
||||
/** For a game Steam installed during the running session: may run in it, unless a game is running. */
|
||||
val inSession: Boolean = false,
|
||||
) {
|
||||
val key get() = when {
|
||||
pics != null -> "pics"
|
||||
component != null -> "c:$component"
|
||||
else -> "g:" + (game?.let { appKey(it) } ?: "")
|
||||
}
|
||||
/** Needs the network: a download, or Steam asked anonymously. */
|
||||
val online get() = component != null || pics != null
|
||||
}
|
||||
|
||||
private val seq = java.util.concurrent.atomic.AtomicLong()
|
||||
/** What the user asked for first, then in the order queued. */
|
||||
private val jobs = PriorityBlockingQueue<Job>(16, compareByDescending<Job> { it.asked }.thenBy { it.seq })
|
||||
@Volatile private var draining = false
|
||||
private val queuedKeys = java.util.Collections.synchronizedSet(HashSet<String>())
|
||||
/** The games waiting on a component's download, to turn it on for when it lands. */
|
||||
private val waiting = HashMap<String, MutableList<Library.SteamGame>>()
|
||||
@Volatile private var worker: Thread? = null
|
||||
private const val PAUSE_MS = 15_000L
|
||||
|
||||
/**
|
||||
* [game]'s list worked out again on the worker, and its components queued: [asked] when the
|
||||
* user is waiting (its page is open, or they installed or added it).
|
||||
*/
|
||||
fun queueGame(context: Context, game: Library.SteamGame, asked: Boolean) {
|
||||
if (appKey(game) == null) return
|
||||
enqueue(context, Job(game, null, asked, seq.incrementAndGet()))
|
||||
}
|
||||
|
||||
private fun queueDownload(context: Context, id: String, asked: Boolean, inSession: Boolean = false) =
|
||||
enqueue(context, Job(null, id, asked, seq.incrementAndGet(), inSession = inSession))
|
||||
|
||||
/** Adds [job] unless the same one is queued (a game or a component, once each). */
|
||||
private fun enqueue(context: Context, job: Job) {
|
||||
if (!queuedKeys.add(job.key)) {
|
||||
// Asked for now: ahead of what nobody asked for, so the open page does not wait behind the sweep.
|
||||
if (job.asked && jobs.removeIf { it.key == job.key && !it.asked }) jobs.add(job)
|
||||
if (runNow && !draining) drain(context)
|
||||
return
|
||||
}
|
||||
if (job.component != null) Log.i(TAG, "wincomp: queued ${job.component}")
|
||||
mark(job, true)
|
||||
jobs.add(job)
|
||||
if (runNow) { if (!draining) drain(context) } else ensureWorker(context.applicationContext)
|
||||
}
|
||||
|
||||
/** Whether [job] has to wait: always while a session runs; one nobody asked for also when it is unfavourable. */
|
||||
internal fun mustWait(context: Context, job: Job): Boolean =
|
||||
if (job.inSession && sessionRunning()) gameRunning() || (job.online && !online(context))
|
||||
else sessionRunning() || (job.online && !online(context)) || (!job.asked && unfavourable(context))
|
||||
|
||||
/** A game is running in the session (Steam's reaper, or Proton waiting on its game). */
|
||||
@VisibleForTesting internal var gameRunning: () -> Boolean = { gameProcessRunning() }
|
||||
|
||||
private fun gameProcessRunning(): Boolean = File("/proc").listFiles()?.any { p ->
|
||||
p.name.all(Char::isDigit) && runCatching { String(File(p, "cmdline").readBytes()).replace('\u0000', ' ') }.getOrNull()
|
||||
?.let { it.contains("SteamLaunch AppId=") || it.contains("proton waitforexitandrun") } == true
|
||||
} == true
|
||||
|
||||
/**
|
||||
* A game Steam installed during the running session: its list worked out and its components
|
||||
* readied in the session (one job, low priority), so a launch right after finds them; it
|
||||
* waits while a game runs. All other work stays paused until the session ends.
|
||||
*/
|
||||
fun queueInSession(context: Context, game: Library.SteamGame) {
|
||||
if (appKey(game) == null) return
|
||||
enqueue(context, Job(game, null, true, seq.incrementAndGet(), inSession = true))
|
||||
}
|
||||
|
||||
/** Runs the queued jobs on this thread until the queue is empty or the next one has to wait. Tests. */
|
||||
@VisibleForTesting
|
||||
internal fun drain(context: Context) {
|
||||
draining = true
|
||||
try {
|
||||
while (true) {
|
||||
val job = next(context) ?: return
|
||||
queuedKeys.remove(job.key)
|
||||
run(context, job)
|
||||
}
|
||||
} finally {
|
||||
draining = false
|
||||
}
|
||||
}
|
||||
|
||||
/** The first job in order that may run now, taken off the queue; null when none may. */
|
||||
private fun next(context: Context): Job? = synchronized(jobs) {
|
||||
val job = jobs.sortedWith(order).firstOrNull { !mustWait(context, it) } ?: return null
|
||||
jobs.remove(job)
|
||||
job
|
||||
}
|
||||
|
||||
private val order = compareByDescending<Job> { it.asked }.thenBy { it.seq }
|
||||
|
||||
@VisibleForTesting internal fun queued(): List<String> = jobs.sortedWith(compareByDescending<Job> { it.asked }.thenBy { it.seq }).map { it.key }
|
||||
|
||||
@Synchronized
|
||||
private fun ensureWorker(app: Context) {
|
||||
if (worker?.isAlive == true) return
|
||||
worker = Thread({
|
||||
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND)
|
||||
while (true) {
|
||||
// Something queued, then the first that may run (one for a game installed in the
|
||||
// session goes ahead of paused work); else a look again in a while.
|
||||
try { jobs.put(jobs.take()) } catch (e: InterruptedException) { return@Thread }
|
||||
val job = next(app)
|
||||
if (job == null) {
|
||||
try { Thread.sleep(PAUSE_MS) } catch (e: InterruptedException) { return@Thread }
|
||||
continue
|
||||
}
|
||||
queuedKeys.remove(job.key)
|
||||
WinComponents.background { run(app, job) }
|
||||
}
|
||||
}, "auto-components").apply { isDaemon = true; start() }
|
||||
}
|
||||
|
||||
private fun run(context: Context, job: Job) {
|
||||
try {
|
||||
when {
|
||||
job.pics != null -> SteamAppInfo.lookup(context, job.pics)
|
||||
job.component != null -> download(context, job.component, job.asked, job.inSession)
|
||||
else -> job.game?.let { apply(context, it, job.asked, job.inSession) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "wincomp: ${job.key}: ${e.javaClass.simpleName}: ${e.message}")
|
||||
} finally {
|
||||
mark(job, false)
|
||||
changes.value = changes.value + 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [game]'s list (worked out again only when what it is read from changed), its automatic
|
||||
* components that are downloaded turned on, and the rest queued for download.
|
||||
*/
|
||||
private fun apply(context: Context, game: Library.SteamGame, asked: Boolean, inSession: Boolean = false) {
|
||||
val key = appKey(game) ?: return
|
||||
val folder = game.gameFiles
|
||||
if (game.library == Library.ADDED && game.source == Library.ADDED && folder != null && SteamMatch.get(context, folder.path) == null) {
|
||||
AddedGames.single(context, folder.path)?.let { SteamMatch.resolve(context, folder.path, it.exe, it.name) }
|
||||
}
|
||||
// A GOG or Epic game installed before its sidecar kept the store's list: read it once from the store.
|
||||
val store = com.droiddeck.launcher.stores.Store.byId(game.source)
|
||||
if (folder != null && (store == com.droiddeck.launcher.stores.Store.GOG || store == com.droiddeck.launcher.stores.Store.EPIC)) StoreListBackfill.fill(context, folder)
|
||||
// Its Steam app, when the client never saw it: Steam asked anonymously now if the user is
|
||||
// waiting on this game (a pass over every game asks for all of them in one go, [sweep]).
|
||||
if (asked && online(context)) steamAppOf(context, game)?.let { SteamAppInfo.lookup(context, listOf(it)) }
|
||||
val findings = findings(context, game)
|
||||
val catalog = (catalogOf(context, false) ?: catalogOf(context, true))?.associateBy { it.name } ?: return
|
||||
val wanted = autoPicks(findings, catalog)
|
||||
val user = WinComponents.selection(context, key).user
|
||||
val installed = installedOf(context)
|
||||
val kept = LinkedHashMap<String, WinComponents.AutoReason>()
|
||||
val missing = ArrayList<String>()
|
||||
for ((id, reason) in wanted) {
|
||||
if (user[id] == false) { kept[id] = reason; continue }
|
||||
if (id in installed) { kept[id] = reason; continue }
|
||||
synchronized(waiting) { waiting.getOrPut(id) { ArrayList() }.let { if (it.none { g -> appKey(g) == key }) it.add(game) } }
|
||||
queueDownload(context, id, asked, inSession)
|
||||
missing.add(id)
|
||||
}
|
||||
// In a session the launch waits a little for these (droiddeck-wincomponents reads the list).
|
||||
if (inSession) Pending.set(context, key, missing)
|
||||
val before = WinComponents.selection(context, key).auto
|
||||
if (before.keys != kept.keys) {
|
||||
WinComponents.setAuto(context, key, kept)
|
||||
Log.i(TAG, "${game.name}: on by itself ${kept.keys.joinToString().ifEmpty { "(none)" }}")
|
||||
}
|
||||
changes.value = changes.value + 1
|
||||
}
|
||||
|
||||
/** One component downloaded and installed, then turned on for every game waiting on it. */
|
||||
private fun download(context: Context, id: String, asked: Boolean, inSession: Boolean = false) {
|
||||
val catalog = catalogOf(context, true)?.associateBy { it.name } ?: run { Pending.drop(context, id); return }
|
||||
if (id !in installedOf(context)) {
|
||||
val c = catalog[id] ?: run { Pending.drop(context, id); return }
|
||||
val started = System.currentTimeMillis()
|
||||
downloading.value = downloading.value + (id to WinComponents.Progress(id, "", WinComponents.Phase.DOWNLOAD, -1))
|
||||
val problem = try {
|
||||
installer(context, c, catalog) { p -> downloading.value = downloading.value + (id to p) }
|
||||
} finally {
|
||||
downloading.value = downloading.value - id
|
||||
}
|
||||
if (problem != null) { Log.w(TAG, "wincomp: $id did not install: $problem"); Pending.drop(context, id); return }
|
||||
Log.i(TAG, "wincomp: $id installed in ${System.currentTimeMillis() - started}ms")
|
||||
}
|
||||
val games = synchronized(waiting) { waiting.remove(id).orEmpty() }
|
||||
games.forEach { apply(context, it, asked, inSession) }
|
||||
Pending.drop(context, id)
|
||||
}
|
||||
|
||||
/**
|
||||
* The components still being readied for games installed in the session, where the launch
|
||||
* reads them (~/.config/droiddeck/wincomponents-pending.json in the runtime).
|
||||
*/
|
||||
internal object Pending {
|
||||
private fun file(context: Context) = File(com.droiddeck.launcher.runtime.LinuxRuntime.rootDir(context), "root/.config/droiddeck/wincomponents-pending.json")
|
||||
|
||||
@Synchronized fun read(context: Context): JSONObject =
|
||||
runCatching { JSONObject(file(context).readText()) }.getOrDefault(JSONObject()).let { it.optJSONObject("games") ?: JSONObject() }
|
||||
|
||||
@Synchronized fun set(context: Context, key: String, ids: List<String>) {
|
||||
val games = read(context)
|
||||
if (ids.isEmpty()) games.remove(key) else games.put(key, JSONArray(ids))
|
||||
write(context, games)
|
||||
}
|
||||
|
||||
@Synchronized fun drop(context: Context, id: String) {
|
||||
val games = read(context)
|
||||
for (k in games.keys().asSequence().toList()) {
|
||||
val left = games.optJSONArray(k)?.let { a -> (0 until a.length()).map { a.optString(it) } }.orEmpty() - id
|
||||
if (left.isEmpty()) games.remove(k) else games.put(k, JSONArray(left))
|
||||
}
|
||||
write(context, games)
|
||||
}
|
||||
|
||||
private fun write(context: Context, games: JSONObject) {
|
||||
val f = file(context)
|
||||
runCatching {
|
||||
f.parentFile?.mkdirs()
|
||||
val tmp = File(f.path + ".tmp")
|
||||
tmp.writeText(JSONObject().put("games", games).toString())
|
||||
if (!tmp.renameTo(f)) { f.delete(); tmp.renameTo(f) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the list, kept per game ----
|
||||
|
||||
private const val FINDINGS = "wincomp-findings.json"
|
||||
|
||||
/** What the list is read from, by modification time: a change means it is worked out again. */
|
||||
internal fun signature(context: Context, game: Library.SteamGame): String {
|
||||
val folder = game.gameFiles
|
||||
val parts = listOfNotNull(
|
||||
folder?.lastModified(),
|
||||
folder?.let { SteamRedists.manifest(it, game.appId)?.lastModified() },
|
||||
folder?.let { StoreGameSidecar.file(it).lastModified() },
|
||||
folder?.let { File(it, "fuel.json").lastModified() },
|
||||
SteamAppInfo.appinfoFile(context).lastModified(),
|
||||
folder?.let { SteamMatch.get(context, it.path) }?.let { "${it.appId}/${it.certainty}" },
|
||||
steamAppOf(context, game)?.let { SteamAppInfo.state(context, it) },
|
||||
)
|
||||
return parts.joinToString("|")
|
||||
}
|
||||
|
||||
/** [game]'s list: the one kept when nothing it is read from changed, else worked out again and kept. Worker only. */
|
||||
internal fun findings(context: Context, game: Library.SteamGame): List<WinCompSources.Finding> {
|
||||
val key = appKey(game) ?: return emptyList()
|
||||
val sig = signature(context, game)
|
||||
val all = readFindings(context)
|
||||
all.optJSONObject(key)?.takeIf { it.optString("sig") == sig }?.let { return decode(it.optJSONArray("findings")) }
|
||||
val fresh = WinCompSources.forGame(context, game)
|
||||
writeFindings(context, all.put(key, JSONObject().put("sig", sig).put("findings", encode(fresh))))
|
||||
return fresh
|
||||
}
|
||||
|
||||
/** The list kept for [appKey], without working anything out; null before the first time. */
|
||||
fun cachedFindings(context: Context, appKey: String): List<WinCompSources.Finding>? =
|
||||
readFindings(context).optJSONObject(appKey)?.let { decode(it.optJSONArray("findings")) }
|
||||
|
||||
@Synchronized
|
||||
private fun readFindings(context: Context): JSONObject =
|
||||
runCatching { JSONObject(AtomicFile(File(context.filesDir, FINDINGS)).readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
|
||||
|
||||
@Synchronized
|
||||
private fun writeFindings(context: Context, json: JSONObject) {
|
||||
val atomic = AtomicFile(File(context.filesDir, FINDINGS))
|
||||
val out = atomic.startWrite()
|
||||
try { out.write(json.toString().toByteArray()); atomic.finishWrite(out) } catch (e: Exception) { atomic.failWrite(out) }
|
||||
}
|
||||
|
||||
private fun encode(list: List<WinCompSources.Finding>) = JSONArray().apply {
|
||||
list.forEach { f ->
|
||||
put(JSONObject().put("original", f.original).put("origin", f.origin.name).put("auto", f.auto).put("args", JSONArray(f.args))
|
||||
.apply { f.component?.let { put("component", it) } })
|
||||
}
|
||||
}
|
||||
|
||||
private fun decode(arr: JSONArray?): List<WinCompSources.Finding> = if (arr == null) emptyList() else (0 until arr.length()).mapNotNull { i ->
|
||||
val o = arr.optJSONObject(i) ?: return@mapNotNull null
|
||||
val origin = runCatching { WinCompSources.Origin.valueOf(o.optString("origin")) }.getOrNull() ?: return@mapNotNull null
|
||||
val args = o.optJSONArray("args")?.let { a -> (0 until a.length()).map { a.optString(it) } }.orEmpty()
|
||||
WinCompSources.Finding(o.optString("component").takeIf { it.isNotEmpty() }, o.optString("original"), origin, o.optBoolean("auto"), args)
|
||||
}
|
||||
|
||||
// ---- when ----
|
||||
|
||||
/** Before a launch from DroidDeck: nothing but a queued look (it waits for the session to end). */
|
||||
fun beforeLaunch(context: Context, game: Library.SteamGame) = queueGame(context, game, asked = false)
|
||||
|
||||
/** After an install or an add: the user did it, so it does not wait on a metered network. */
|
||||
fun refreshLater(context: Context, game: Library.SteamGame) = queueGame(context, game, asked = true)
|
||||
|
||||
/**
|
||||
* Every game, whatever its source, at app start and after each session (Steam installs its
|
||||
* games without a word to DroidDeck; older GOG and Epic installs get their list read once):
|
||||
* only queued, one game at a time, behind anything asked for, under the same conditions. A
|
||||
* game whose files did not change costs a look at their times.
|
||||
*/
|
||||
fun sweep(context: Context, games: List<Library.SteamGame>) {
|
||||
// First every Steam app these games are that the client never saw, asked of Steam in one
|
||||
// connection; then each game's list.
|
||||
val apps = games.mapNotNull { steamAppOf(context, it) }.distinct()
|
||||
if (apps.isNotEmpty() && SteamAppInfo.needLookup(context, apps).isNotEmpty()) enqueue(context, Job(null, null, false, seq.incrementAndGet(), pics = apps))
|
||||
games.forEach { queueGame(context, it, asked = false) }
|
||||
}
|
||||
|
||||
/** The Steam app whose list a game takes: a Steam game's own, or an added game's match. */
|
||||
private fun steamAppOf(context: Context, game: Library.SteamGame): Int? =
|
||||
if (game.library != Library.ADDED) game.appId.takeIf { it > 0 }
|
||||
else game.gameFiles?.let { SteamMatch.get(context, it.path) }?.appId
|
||||
|
||||
/** The default network is there and validated (reaches the internet). */
|
||||
fun isOnline(context: Context): Boolean {
|
||||
val cm = context.getSystemService(ConnectivityManager::class.java) ?: return false
|
||||
val caps = cm.getNetworkCapabilities(cm.activeNetwork ?: return false) ?: return false
|
||||
return caps.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET) &&
|
||||
caps.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
}
|
||||
|
||||
/** Battery saver, thermal status moderate or worse, or a metered network. */
|
||||
private fun constrained(context: Context): Boolean {
|
||||
val power = context.getSystemService(PowerManager::class.java)
|
||||
if (power?.isPowerSaveMode == true) return true
|
||||
if (Build.VERSION.SDK_INT >= 29 && (power?.currentThermalStatus ?: 0) >= PowerManager.THERMAL_STATUS_MODERATE) return true
|
||||
return context.getSystemService(ConnectivityManager::class.java)?.isActiveNetworkMetered == true
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,9 @@ object DependencyDetector {
|
||||
val lower = f.name.lowercase(Locale.US)
|
||||
if (f.isDirectory) {
|
||||
if (SKIP_DIR_RE.matches(lower)) continue
|
||||
// Unity's <Game>_Data/Managed holds the game's own Mono assemblies, not a .NET
|
||||
// Framework to install.
|
||||
if (lower == "managed" && dir.name.endsWith("_Data", ignoreCase = true)) continue
|
||||
val isRedistDir = insideRedist || REDIST_DIR_RE.matches(lower)
|
||||
if (isRedistDir) consider(f, isDir = true, insideRedist = true)
|
||||
// Aggressive coverage: descend redist containers fully, and otherwise sweep the game
|
||||
@@ -157,6 +160,19 @@ object DependencyDetector {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog components one installer stands for, by its path ("_CommonRedist/vcredist/2015/
|
||||
* vc_redist.x64.exe", "DXSETUP.exe"): what a store's install list names. A VC++ installer
|
||||
* with no year is the latest; empty for one that is none of ours.
|
||||
*/
|
||||
fun componentsFor(path: String): List<String> {
|
||||
val p = path.replace('\\', '/').lowercase(Locale.US)
|
||||
return classify(p, p.substringAfterLast('/'), isDir = false).map { if (it == VCREDIST_LOOSE) "vcredist2022" else it }
|
||||
}
|
||||
|
||||
/** The name the catalog component [name] goes by, for a reason line. */
|
||||
fun label(name: String): String = LABELS[name] ?: name
|
||||
|
||||
/** A loose VC++ installer resolves to the latest 14.x only if no specific vcredistYYYY was found. */
|
||||
private fun resolveLooseVcredist(hits: LinkedHashMap<String, Recommendation>) {
|
||||
val loose = hits.remove(VCREDIST_LOOSE) ?: return
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.GameStorage
|
||||
import com.droiddeck.launcher.session.SessionState
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* A Steam game whose download finishes during a session, noticed by its appmanifest (a new one,
|
||||
* or StateFlags turning to 4, fully installed) with a light look every five seconds while the
|
||||
* session runs, and handed to [AutoComponents] as a single job that may run in the session, so its
|
||||
* Windows components are in place when the user plays it right after the download.
|
||||
*/
|
||||
object SessionInstalls {
|
||||
private const val TAG = "SessionInstalls"
|
||||
private const val POLL_MS = 5_000L
|
||||
|
||||
/** One appmanifest as read: the app, its name and folder, whether it is fully installed. */
|
||||
class Manifest(val appId: Int, val name: String, val installDir: String, val installed: Boolean, val steamapps: File)
|
||||
|
||||
private val APPID = Regex("\"appid\"\\s*\"(\\d+)\"", RegexOption.IGNORE_CASE)
|
||||
private val NAME = Regex("\"name\"\\s*\"([^\"]*)\"", RegexOption.IGNORE_CASE)
|
||||
private val DIR = Regex("\"installdir\"\\s*\"([^\"]*)\"", RegexOption.IGNORE_CASE)
|
||||
private val STATE = Regex("\"StateFlags\"\\s*\"(\\d+)\"", RegexOption.IGNORE_CASE)
|
||||
|
||||
internal fun parse(file: File): Manifest? = runCatching {
|
||||
val text = file.readText()
|
||||
val id = APPID.find(text)?.groupValues?.get(1)?.toInt() ?: return null
|
||||
Manifest(id, NAME.find(text)?.groupValues?.get(1).orEmpty(), DIR.find(text)?.groupValues?.get(1).orEmpty(),
|
||||
STATE.find(text)?.groupValues?.get(1)?.toIntOrNull() == 4, file.parentFile!!)
|
||||
}.getOrNull()
|
||||
|
||||
/** The libraries the session's client installs to, as host folders. */
|
||||
internal fun steamappsDirs(context: Context): List<File> = listOfNotNull(
|
||||
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/steamapps"),
|
||||
GameStorage.effective(context)?.let { File(it.path, "steamapps") },
|
||||
).filter { it.isDirectory }
|
||||
|
||||
/**
|
||||
* Keeps what the libraries hold between looks: a manifest re-read only when its time changed.
|
||||
* [poll] returns the apps that became fully installed since the last look (none on the first).
|
||||
*/
|
||||
internal class Watch(private val dirs: () -> List<File>) {
|
||||
private val seen = HashMap<String, Pair<Long, Manifest?>>()
|
||||
private var first = true
|
||||
|
||||
fun poll(): List<Manifest> {
|
||||
val fresh = ArrayList<Manifest>()
|
||||
for (dir in dirs()) for (f in dir.listFiles { x -> x.isFile && x.name.startsWith("appmanifest_") && x.name.endsWith(".acf") }.orEmpty()) {
|
||||
val before = seen[f.path]
|
||||
if (before != null && before.first == f.lastModified()) continue
|
||||
val now = parse(f)
|
||||
seen[f.path] = f.lastModified() to now
|
||||
if (!first && now != null && now.installed && before?.second?.installed != true) fresh.add(now)
|
||||
}
|
||||
first = false
|
||||
return fresh
|
||||
}
|
||||
}
|
||||
|
||||
/** A finished install as the Games tab would list it. */
|
||||
internal fun gameOf(m: Manifest): Library.SteamGame =
|
||||
Library.SteamGame(m.appId, m.name.ifEmpty { m.appId.toString() }, null, m.steamapps.parentFile?.path ?: "steam",
|
||||
gameFiles = File(m.steamapps, "common/${m.installDir}"), source = Library.SOURCE_STEAM)
|
||||
|
||||
/**
|
||||
* Looks every five seconds while the session runs, on a thread of its own at background
|
||||
* priority; each finished install becomes one job for its Windows components.
|
||||
*/
|
||||
fun watch(context: Context) {
|
||||
val app = context.applicationContext
|
||||
Thread({
|
||||
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_BACKGROUND)
|
||||
val watch = Watch { steamappsDirs(app) }
|
||||
try {
|
||||
// The session is still starting when this is called: up to two minutes for it.
|
||||
var waited = 0L
|
||||
while (!SessionState.running && waited < 120_000L) { Thread.sleep(POLL_MS); waited += POLL_MS }
|
||||
watch.poll()
|
||||
while (SessionState.running) {
|
||||
for (m in watch.poll()) {
|
||||
Log.i(TAG, "wincomp: ${m.name} (${m.appId}) finished installing in the session")
|
||||
AutoComponents.queueInSession(app, gameOf(m))
|
||||
}
|
||||
Thread.sleep(POLL_MS)
|
||||
}
|
||||
} catch (e: InterruptedException) {
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "install watch: ${e.message}")
|
||||
}
|
||||
}, "session-installs").apply { isDaemon = true }.start()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import android.util.Log
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.DataInputStream
|
||||
import java.io.File
|
||||
import java.io.RandomAccessFile
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/**
|
||||
* Steam's own data for an app, from the client in the runtime: its app-info cache
|
||||
* (Steam/appcache/appinfo.vdf, the PICS data the client keeps for every app it has seen). What is
|
||||
* read is which Steamworks Shared depots (the VC++, DirectX... redistributables of app 228980) the
|
||||
* app's depots point at, and its name. Kept per appid in the app's files, so it works offline; an
|
||||
* app the client has not seen is looked for again after a week.
|
||||
*/
|
||||
object SteamAppInfo {
|
||||
private const val TAG = "SteamAppInfo"
|
||||
private const val CACHE = "steam-appinfo.json"
|
||||
private const val RETRY_AFTER_MS = 7L * 24 * 3600 * 1000
|
||||
/** The Steamworks Shared app the redistributable depots belong to. */
|
||||
const val SHARED_APP = "228980"
|
||||
|
||||
class Info(val name: String, val sharedDepots: List<String>)
|
||||
|
||||
fun appinfoFile(context: Context) = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/appcache/appinfo.vdf")
|
||||
|
||||
/** The shared depots for [appId]: from the cache, else the client's app-info file; null when unknown. */
|
||||
@Synchronized
|
||||
fun info(context: Context, appId: Int, appinfo: File = appinfoFile(context)): Info? {
|
||||
val cacheFile = AtomicFile(File(context.filesDir, CACHE))
|
||||
val cache = runCatching { JSONObject(cacheFile.readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
|
||||
val now = System.currentTimeMillis()
|
||||
val mtime = appinfo.lastModified()
|
||||
val known = cache.optJSONObject(appId.toString())
|
||||
known?.let { o ->
|
||||
if (!o.optBoolean("miss")) return Info(o.optString("name"), o.optJSONArray("depots").strings())
|
||||
// A miss is read again when the client has written its cache since (the user may have
|
||||
// looked at the app in Steam), else a week later.
|
||||
if (now - o.optLong("at") < RETRY_AFTER_MS && o.optLong("mtime") == mtime) return null
|
||||
}
|
||||
val read = runCatching { read(appinfo, appId) }.onFailure { Log.w(TAG, "appinfo: ${it.javaClass.simpleName}") }.getOrNull()
|
||||
val entry = JSONObject().put("at", now).put("mtime", mtime)
|
||||
// When Steam was last asked anonymously ([lookup]), kept across re-reads of the file.
|
||||
known?.optLong("picsAt")?.takeIf { it > 0 }?.let { entry.put("picsAt", it) }
|
||||
if (read == null) entry.put("miss", true) else entry.put("name", read.name).put("depots", JSONArray(read.sharedDepots))
|
||||
cache.put(appId.toString(), entry)
|
||||
val out = cacheFile.startWrite()
|
||||
try { out.write(cache.toString().toByteArray()); cacheFile.finishWrite(out) } catch (e: Exception) { cacheFile.failWrite(out) }
|
||||
return read
|
||||
}
|
||||
|
||||
/** Steam asked anonymously ([SteamPics]); a test stands in for it. */
|
||||
@androidx.annotation.VisibleForTesting
|
||||
internal var pics: (List<Int>) -> Map<Int, Info?>? = { SteamPics.lookup(it) }
|
||||
|
||||
/**
|
||||
* Of [appIds], those neither the client's cache (appinfo.vdf, read first: free) nor an earlier
|
||||
* answer has, and that Steam was not asked about anonymously in the past week.
|
||||
*/
|
||||
fun needLookup(context: Context, appIds: List<Int>): List<Int> {
|
||||
val wanted = appIds.distinct().filter { it > 0 && info(context, it) == null }
|
||||
if (wanted.isEmpty()) return emptyList()
|
||||
val cache = cache(context)
|
||||
val now = System.currentTimeMillis()
|
||||
return wanted.filter { now - (cache.optJSONObject(it.toString())?.optLong("picsAt") ?: 0L) >= RETRY_AFTER_MS }
|
||||
}
|
||||
|
||||
/**
|
||||
* [appIds] the client never saw, asked of Steam anonymously in one connection ([SteamPics], in
|
||||
* batches) and every answer kept like the client's: an app's depots and name, or a miss tried
|
||||
* again in a week. False when nothing needed asking or Steam could not be reached. Blocking.
|
||||
*/
|
||||
@Synchronized
|
||||
fun lookup(context: Context, appIds: List<Int>): Boolean {
|
||||
val need = needLookup(context, appIds)
|
||||
if (need.isEmpty()) return false
|
||||
val answers = pics(need) ?: return false
|
||||
val cache = cache(context)
|
||||
val now = System.currentTimeMillis()
|
||||
val mtime = appinfoFile(context).lastModified()
|
||||
for (id in need) {
|
||||
val info = answers[id]
|
||||
val entry = JSONObject().put("at", now).put("mtime", mtime).put("picsAt", now)
|
||||
if (info == null) entry.put("miss", true) else entry.put("name", info.name).put("depots", JSONArray(info.sharedDepots)).put("source", "pics")
|
||||
cache.put(id.toString(), entry)
|
||||
}
|
||||
val file = AtomicFile(File(context.filesDir, CACHE))
|
||||
val out = file.startWrite()
|
||||
try { out.write(cache.toString().toByteArray()); file.finishWrite(out) } catch (e: Exception) { file.failWrite(out) }
|
||||
return true
|
||||
}
|
||||
|
||||
/** What is kept for [appId]: "hit", "miss" or "none" (part of a game's list fingerprint). */
|
||||
fun state(context: Context, appId: Int): String = cache(context).optJSONObject(appId.toString())?.let { if (it.optBoolean("miss")) "miss" else "hit" } ?: "none"
|
||||
|
||||
private fun cache(context: Context): JSONObject =
|
||||
runCatching { JSONObject(AtomicFile(File(context.filesDir, CACHE)).readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
|
||||
|
||||
private fun JSONArray?.strings(): List<String> = if (this == null) emptyList() else (0 until length()).map { optString(it) }
|
||||
|
||||
/**
|
||||
* [appId]'s entry in an appinfo.vdf (versions 27, 28 and 29, the last with its keys in a string
|
||||
* table), or null when the file does not have it.
|
||||
*/
|
||||
internal fun read(file: File, appId: Int): Info? {
|
||||
if (!file.isFile) return null
|
||||
RandomAccessFile(file, "r").use { raf ->
|
||||
val head = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN)
|
||||
raf.readFully(head.array(), 0, 8)
|
||||
val magic = head.getInt(0)
|
||||
val version = magic and 0xFF
|
||||
if (magic ushr 8 != 0x075644 || version !in 0x27..0x29) return null
|
||||
var strings: List<String>? = null
|
||||
var pos = 8L
|
||||
if (version >= 0x29) {
|
||||
raf.readFully(head.array(), 8, 8)
|
||||
val tableAt = head.getLong(8)
|
||||
strings = readStrings(raf, tableAt)
|
||||
pos = 16L
|
||||
}
|
||||
val fixed = if (version >= 0x28) 4 + 4 + 8 + 20 + 4 + 20 else 4 + 4 + 8 + 20 + 4
|
||||
val entry = ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN)
|
||||
while (pos + 8 <= raf.length()) {
|
||||
raf.seek(pos)
|
||||
raf.readFully(entry.array(), 0, 8)
|
||||
val id = entry.getInt(0)
|
||||
val size = entry.getInt(4).toLong() and 0xFFFFFFFFL
|
||||
if (id == 0) return null
|
||||
if (id == appId) {
|
||||
if (size > 64L * 1024 * 1024) return null
|
||||
val body = ByteArray(size.toInt())
|
||||
raf.readFully(body)
|
||||
val kv = ByteBuffer.wrap(body, fixed, body.size - fixed).order(ByteOrder.LITTLE_ENDIAN)
|
||||
val tree = readMap(kv, strings)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val app = (tree["appinfo"] as? Map<String, Any>) ?: tree
|
||||
return infoOf(app)
|
||||
}
|
||||
pos += 8 + size
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
internal fun infoOf(app: Map<String, Any>): Info {
|
||||
val name = (app["common"] as? Map<String, Any>)?.get("name") as? String ?: ""
|
||||
val depots = (app["depots"] as? Map<String, Any>).orEmpty()
|
||||
val shared = depots.mapNotNull { (key, value) ->
|
||||
val depot = value as? Map<String, Any> ?: return@mapNotNull null
|
||||
key.takeIf { (depot["depotfromapp"] as? String) == SHARED_APP || (key in SteamRedists.DEPOTS && depot["sharedinstall"] != null) }
|
||||
}
|
||||
return Info(name, shared)
|
||||
}
|
||||
|
||||
private fun readStrings(raf: RandomAccessFile, at: Long): List<String> {
|
||||
raf.seek(at)
|
||||
val count = Integer.reverseBytes(raf.readInt())
|
||||
val rest = ByteArray((raf.length() - at - 4).toInt().coerceAtLeast(0))
|
||||
raf.readFully(rest)
|
||||
val out = ArrayList<String>(count)
|
||||
var start = 0
|
||||
for (i in rest.indices) {
|
||||
if (out.size == count) break
|
||||
if (rest[i] == 0.toByte()) { out.add(String(rest, start, i - start, Charsets.UTF_8)); start = i + 1 }
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun cstring(b: ByteBuffer): String {
|
||||
val start = b.position()
|
||||
while (b.get() != 0.toByte()) { }
|
||||
val bytes = ByteArray(b.position() - start - 1)
|
||||
b.position(start); b.get(bytes); b.get()
|
||||
return String(bytes, Charsets.UTF_8)
|
||||
}
|
||||
|
||||
/** One binary key-value map, to its end marker; keys by name, or by index into [strings]. */
|
||||
private fun readMap(b: ByteBuffer, strings: List<String>?): Map<String, Any> {
|
||||
val out = LinkedHashMap<String, Any>()
|
||||
while (b.hasRemaining()) {
|
||||
val type = b.get().toInt() and 0xFF
|
||||
if (type == 0x08) return out
|
||||
val key = if (strings != null) strings.getOrElse(b.int) { "" } else cstring(b)
|
||||
when (type) {
|
||||
0x00 -> out[key] = readMap(b, strings)
|
||||
0x01 -> out[key] = cstring(b)
|
||||
0x02 -> out[key] = b.int.toString()
|
||||
0x03 -> out[key] = b.float.toString()
|
||||
0x07, 0x0A -> out[key] = b.long.toString()
|
||||
else -> return out
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Which Steam app a game added in DroidDeck is, kept once per game (by its folder) and used by
|
||||
* everything that asks: its art ([AddedGameArt]) and its Windows components ([WinCompSources]),
|
||||
* so the two never disagree.
|
||||
*
|
||||
* [Certainty.FILES]: the game's own files name the appid (steam_appid.txt, also under
|
||||
* steam_settings/ or a parent folder; an emulator ini; Steam's appmanifest) - certain.
|
||||
* [Certainty.NAME]: Steam's store search returned a game of the same name - a guess.
|
||||
* [Certainty.NONE]: neither; a miss is tried again after a week.
|
||||
*/
|
||||
object SteamMatch {
|
||||
enum class Certainty { FILES, NAME, NONE }
|
||||
|
||||
/** [source]: what gave the appid (GameIdentifier's source, or "store search"). */
|
||||
class Match(val appId: Int?, val certainty: Certainty, val source: String, val checkedAt: Long)
|
||||
|
||||
private const val FILE = "steam-matches.json"
|
||||
private const val RETRY_AFTER_MS = 7L * 24 * 3600 * 1000
|
||||
|
||||
private fun store(context: Context) = AtomicFile(File(context.filesDir, FILE))
|
||||
|
||||
@Synchronized
|
||||
private fun all(context: Context): JSONObject =
|
||||
runCatching { JSONObject(store(context).readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
|
||||
|
||||
@Synchronized
|
||||
fun get(context: Context, key: String): Match? = all(context).optJSONObject(key)?.let { o ->
|
||||
Match(
|
||||
o.optInt("appId", 0).takeIf { it > 0 },
|
||||
runCatching { Certainty.valueOf(o.optString("certainty")) }.getOrDefault(Certainty.NONE),
|
||||
o.optString("source"), o.optLong("at"),
|
||||
)
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun put(context: Context, key: String, match: Match) {
|
||||
val json = all(context).put(
|
||||
key, JSONObject().put("certainty", match.certainty.name).put("source", match.source).put("at", match.checkedAt)
|
||||
.apply { match.appId?.let { put("appId", it) } },
|
||||
)
|
||||
val atomic = store(context)
|
||||
val out = atomic.startWrite()
|
||||
try { out.write(json.toString().toByteArray()); atomic.finishWrite(out) } catch (e: Exception) { atomic.failWrite(out) }
|
||||
}
|
||||
|
||||
/**
|
||||
* The match for the game in [key] (its folder), [exe] its exe and [name] its title: its files
|
||||
* first, always read again; else the one kept (a name match stays, a miss for a week); else
|
||||
* [legacy], an appid an earlier build's art lookup kept; else [search] on the name, when given.
|
||||
* Blocking ([search] asks the network).
|
||||
*/
|
||||
fun resolve(context: Context, key: String, exe: File, name: String, legacy: File? = null, search: ((String) -> Int?)? = null): Match {
|
||||
val now = System.currentTimeMillis()
|
||||
val identity = runCatching { GameIdentifier.identify(exe) }.getOrNull()
|
||||
identity?.appId?.let { id ->
|
||||
return Match(id, Certainty.FILES, identity.source.name.lowercase(), now).also { put(context, key, it) }
|
||||
}
|
||||
val known = get(context, key)
|
||||
if (known != null && known.certainty == Certainty.NAME) return known
|
||||
if (known != null && known.certainty == Certainty.NONE && now - known.checkedAt < RETRY_AFTER_MS) return known
|
||||
legacy?.takeIf { it.isFile }?.let { runCatching { it.readText().trim().toInt() }.getOrNull() }?.takeIf { it > 0 }?.let { id ->
|
||||
return Match(id, Certainty.NAME, "store search", now).also { put(context, key, it) }
|
||||
}
|
||||
if (search == null) return known?.takeIf { it.certainty != Certainty.FILES } ?: Match(null, Certainty.NONE, "", 0)
|
||||
val id = runCatching { search(name) }.getOrNull()
|
||||
val match = if (id != null && id > 0) Match(id, Certainty.NAME, "store search", now) else Match(null, Certainty.NONE, "", now)
|
||||
put(context, key, match)
|
||||
return match
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import org.json.JSONObject
|
||||
import java.io.ByteArrayInputStream
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.DataInputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.security.SecureRandom
|
||||
import java.util.zip.GZIPInputStream
|
||||
import javax.net.ssl.SSLSocket
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
|
||||
/**
|
||||
* Steam's own app info (PICS) for apps the client in the runtime has never seen, asked of Steam
|
||||
* anonymously: a connection to one of Steam's connection managers (the list from Steam's public
|
||||
* ISteamDirectory/GetCMListForConnect, the WebSocket transport Steam's web clients use), an
|
||||
* anonymous logon - no account, nothing of the user's Steam sign-in - a request for the apps'
|
||||
* access tokens and then their product info, the app KeyValues read for their Steamworks Shared
|
||||
* depots and name, and the connection closed. What steamcmd's anonymous `app_info_print` sees.
|
||||
*/
|
||||
object SteamPics {
|
||||
private const val TAG = "SteamPics"
|
||||
private const val CM_LIST = "https://api.steampowered.com/ISteamDirectory/GetCMListForConnect/v1/?cellid=0&format=json"
|
||||
/** At most this many apps per request. */
|
||||
const val BATCH = 25
|
||||
private const val PROTO = 0x80000000.toInt()
|
||||
private const val EMSG_MULTI = 1
|
||||
private const val EMSG_LOGON = 5514
|
||||
private const val EMSG_LOGON_RESPONSE = 751
|
||||
private const val EMSG_LOGOFF = 706
|
||||
private const val EMSG_TOKENS_REQUEST = 8905
|
||||
private const val EMSG_TOKENS_RESPONSE = 8906
|
||||
private const val EMSG_INFO_REQUEST = 8903
|
||||
private const val EMSG_INFO_RESPONSE = 8904
|
||||
private const val PROTOCOL_VERSION = 65580
|
||||
/** An anonymous user's SteamID: universe Public, account type AnonUser, instance 0. */
|
||||
private const val ANON_STEAMID = (1L shl 56) or (10L shl 52)
|
||||
|
||||
/**
|
||||
* [appIds]' info, one connection for all of them in batches of [BATCH]: each known app's
|
||||
* info, null for an app Steam does not know. Null when Steam could not be reached. Blocking.
|
||||
*/
|
||||
fun lookup(appIds: List<Int>): Map<Int, SteamAppInfo.Info?>? {
|
||||
if (appIds.isEmpty()) return emptyMap()
|
||||
val host = runCatching { cmHost() }.getOrNull() ?: return null
|
||||
return try {
|
||||
Wss(host, 20_000).use { ws ->
|
||||
ws.send(message(EMSG_LOGON, header(ANON_STEAMID, 0), Proto().varint(1, PROTOCOL_VERSION.toLong()).varint(7, 16).string(6, "english").bytes()))
|
||||
var steamId = 0L
|
||||
var session = 0
|
||||
// The logon's answer (other messages before it are passed over).
|
||||
loop@ while (true) for ((emsg, hdr, body) in messages(ws.receive() ?: return null)) if (emsg == EMSG_LOGON_RESPONSE) {
|
||||
val h = fields(hdr); val b = fields(body)
|
||||
if ((b[1]?.firstOrNull() as? Long ?: 0L) != 1L) { Log.w(TAG, "anonymous logon refused: ${b[1]}"); return null }
|
||||
steamId = h[1]?.firstOrNull() as? Long ?: 0L
|
||||
session = (h[2]?.firstOrNull() as? Long ?: 0L).toInt()
|
||||
break@loop
|
||||
}
|
||||
val out = HashMap<Int, SteamAppInfo.Info?>()
|
||||
for (batch in batches(appIds)) {
|
||||
val tokens = HashMap<Int, Long>()
|
||||
ws.send(message(EMSG_TOKENS_REQUEST, header(steamId, session), Proto().apply { batch.forEach { varint(2, it.toLong()) } }.bytes()))
|
||||
tokens@ while (true) for ((emsg, _, body) in messages(ws.receive() ?: return null)) if (emsg == EMSG_TOKENS_RESPONSE) {
|
||||
for (t in fields(body)[3].orEmpty()) {
|
||||
val f = fields(t as ByteArray)
|
||||
val id = (f[1]?.firstOrNull() as? Long)?.toInt() ?: continue
|
||||
tokens[id] = f[2]?.firstOrNull() as? Long ?: 0L
|
||||
}
|
||||
break@tokens
|
||||
}
|
||||
ws.send(message(EMSG_INFO_REQUEST, header(steamId, session), Proto().apply {
|
||||
batch.forEach { id -> bytes(2, Proto().varint(1, id.toLong()).apply { tokens[id]?.takeIf { it != 0L }?.let { varint(2, it) } }.bytes()) }
|
||||
varint(3, 0)
|
||||
}.bytes()))
|
||||
var pending = true
|
||||
while (pending) for ((emsg, _, body) in messages(ws.receive() ?: return null)) if (emsg == EMSG_INFO_RESPONSE) {
|
||||
val r = parseInfoResponse(body)
|
||||
out.putAll(r.apps)
|
||||
r.unknown.forEach { out[it] = null }
|
||||
pending = r.pending
|
||||
}
|
||||
batch.forEach { if (it !in out) out[it] = null }
|
||||
}
|
||||
runCatching { ws.send(message(EMSG_LOGOFF, header(steamId, session), ByteArray(0))) }
|
||||
Log.i(TAG, "steam pics: ${out.size} apps, ${out.values.count { it?.sharedDepots?.isNotEmpty() == true }} with shared redists")
|
||||
out
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "steam pics: ${e.javaClass.simpleName}: ${e.message}")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** [appIds] in requests of at most [BATCH], each once. */
|
||||
fun batches(appIds: List<Int>): List<List<Int>> = appIds.distinct().chunked(BATCH)
|
||||
|
||||
/** A connection manager for the WebSocket transport, least loaded first as Steam lists them. */
|
||||
private fun cmHost(): String? {
|
||||
val c = URL(CM_LIST).openConnection() as HttpURLConnection
|
||||
c.connectTimeout = 10_000; c.readTimeout = 10_000
|
||||
val body = try { c.inputStream.bufferedReader().readText() } finally { c.disconnect() }
|
||||
val list = JSONObject(body).optJSONObject("response")?.optJSONArray("serverlist") ?: return null
|
||||
return (0 until list.length()).mapNotNull { list.optJSONObject(it) }
|
||||
.firstOrNull { it.optString("type") == "websockets" }?.optString("endpoint")?.substringBefore(':')
|
||||
}
|
||||
|
||||
// ---- the product info answer ----
|
||||
|
||||
class InfoResponse(val apps: Map<Int, SteamAppInfo.Info>, val unknown: List<Int>, val pending: Boolean)
|
||||
|
||||
/** A CMsgClientPICSProductInfoResponse: each app's KeyValues read, the unknown ones, whether more follows. */
|
||||
@VisibleForTesting
|
||||
internal fun parseInfoResponse(body: ByteArray): InfoResponse {
|
||||
val f = fields(body)
|
||||
val apps = HashMap<Int, SteamAppInfo.Info>()
|
||||
for (a in f[1].orEmpty()) {
|
||||
val app = fields(a as ByteArray)
|
||||
val id = (app[1]?.firstOrNull() as? Long)?.toInt() ?: continue
|
||||
val buffer = app[5]?.firstOrNull() as? ByteArray ?: continue
|
||||
val text = String(buffer, Charsets.UTF_8).trimEnd('\u0000')
|
||||
infoFromKeyValues(text)?.let { apps[id] = it }
|
||||
}
|
||||
val unknown = f[2].orEmpty().mapNotNull { (it as? Long)?.toInt() } + f[2].orEmpty().filterIsInstance<ByteArray>().flatMap { packed(it) }
|
||||
return InfoResponse(apps, unknown, (f[6]?.firstOrNull() as? Long ?: 0L) != 0L)
|
||||
}
|
||||
|
||||
/** An app's KeyValues text ("appinfo" { "common" {...} "depots" {...} }) to its name and shared depots. */
|
||||
@VisibleForTesting
|
||||
internal fun infoFromKeyValues(text: String): SteamAppInfo.Info? {
|
||||
// The buffer ends in a NUL, kept or not by whoever handed it over.
|
||||
val tree = runCatching { KeyValues(text.trimEnd('\u0000', ' ', '\n', '\r', '\t')).read() }.getOrNull() ?: return null
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val app = tree["appinfo"] as? Map<String, Any> ?: return null
|
||||
return SteamAppInfo.infoOf(app)
|
||||
}
|
||||
|
||||
/** Valve's text KeyValues: quoted keys and values, nested braces. */
|
||||
private class KeyValues(private val s: String) {
|
||||
private var i = 0
|
||||
fun read(): Map<String, Any> = map(top = true)
|
||||
private fun map(top: Boolean): Map<String, Any> {
|
||||
val out = LinkedHashMap<String, Any>()
|
||||
while (true) {
|
||||
skip()
|
||||
if (i >= s.length) { if (top) return out else throw IOException("unclosed") }
|
||||
if (s[i] == '}') { i++; return out }
|
||||
val key = token()
|
||||
skip()
|
||||
if (i < s.length && s[i] == '{') { i++; out[key] = map(top = false) } else out[key] = token()
|
||||
}
|
||||
}
|
||||
private fun skip() {
|
||||
while (i < s.length) {
|
||||
if (s[i].isWhitespace() || s[i] == '\u0000') i++
|
||||
else if (s.startsWith("//", i)) { while (i < s.length && s[i] != '\n') i++ }
|
||||
else break
|
||||
}
|
||||
}
|
||||
private fun token(): String {
|
||||
if (i < s.length && s[i] == '"') {
|
||||
i++
|
||||
val b = StringBuilder()
|
||||
while (i < s.length && s[i] != '"') {
|
||||
if (s[i] == '\\' && i + 1 < s.length) { i++; b.append(when (s[i]) { 'n' -> '\n'; 't' -> '\t'; else -> s[i] }) } else b.append(s[i])
|
||||
i++
|
||||
}
|
||||
i++
|
||||
return b.toString()
|
||||
}
|
||||
val start = i
|
||||
while (i < s.length && !s[i].isWhitespace() && s[i] != '{' && s[i] != '}') i++
|
||||
if (start == i) throw IOException("token expected at $i")
|
||||
return s.substring(start, i)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- messages ----
|
||||
|
||||
private fun header(steamId: Long, session: Int) = Proto().fixed64(1, steamId).varint(2, session.toLong()).bytes()
|
||||
|
||||
private fun message(emsg: Int, header: ByteArray, body: ByteArray): ByteArray =
|
||||
ByteBuffer.allocate(8 + header.size + body.size).order(ByteOrder.LITTLE_ENDIAN)
|
||||
.putInt(emsg or PROTO).putInt(header.size).put(header).put(body).array()
|
||||
|
||||
/** One message off the wire as (emsg, header, body); a Multi unpacked (gunzipped when it says so) into the ones inside. */
|
||||
@VisibleForTesting
|
||||
internal fun messages(data: ByteArray): List<Triple<Int, ByteArray, ByteArray>> {
|
||||
if (data.size < 8) return emptyList()
|
||||
val b = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN)
|
||||
val raw = b.int
|
||||
if (raw and PROTO == 0) return emptyList()
|
||||
val emsg = raw and PROTO.inv()
|
||||
val hl = b.int
|
||||
if (hl < 0 || 8 + hl > data.size) return emptyList()
|
||||
val header = data.copyOfRange(8, 8 + hl)
|
||||
val body = data.copyOfRange(8 + hl, data.size)
|
||||
if (emsg != EMSG_MULTI) return listOf(Triple(emsg, header, body))
|
||||
val f = fields(body)
|
||||
var inner = f[2]?.firstOrNull() as? ByteArray ?: return emptyList()
|
||||
if ((f[1]?.firstOrNull() as? Long ?: 0L) > 0) inner = GZIPInputStream(ByteArrayInputStream(inner)).use { it.readBytes() }
|
||||
val out = ArrayList<Triple<Int, ByteArray, ByteArray>>()
|
||||
val ib = ByteBuffer.wrap(inner).order(ByteOrder.LITTLE_ENDIAN)
|
||||
while (ib.remaining() >= 4) {
|
||||
val len = ib.int
|
||||
if (len < 0 || len > ib.remaining()) break
|
||||
val one = ByteArray(len).also { ib.get(it) }
|
||||
out.addAll(messages(one))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- protobuf, the little needed ----
|
||||
|
||||
/** Writes protobuf fields. */
|
||||
@VisibleForTesting
|
||||
internal class Proto {
|
||||
private val out = ByteArrayOutputStream()
|
||||
private fun raw(v: Long) { var x = v; while (true) { val b = (x and 0x7F).toInt(); x = x ushr 7; if (x == 0L) { out.write(b); return }; out.write(b or 0x80) } }
|
||||
fun varint(field: Int, v: Long) = apply { raw((field shl 3).toLong()); raw(v) }
|
||||
fun fixed64(field: Int, v: Long) = apply { raw(((field shl 3) or 1).toLong()); for (k in 0 until 8) out.write(((v ushr (8 * k)) and 0xFF).toInt()) }
|
||||
fun bytes(field: Int, b: ByteArray) = apply { raw(((field shl 3) or 2).toLong()); raw(b.size.toLong()); out.write(b) }
|
||||
fun string(field: Int, s: String) = bytes(field, s.toByteArray())
|
||||
fun bytes(): ByteArray = out.toByteArray()
|
||||
}
|
||||
|
||||
/** Reads protobuf fields: number to values (Long for varint and fixed, ByteArray for length-delimited). */
|
||||
@VisibleForTesting
|
||||
internal fun fields(b: ByteArray): Map<Int, List<Any>> {
|
||||
val out = HashMap<Int, MutableList<Any>>()
|
||||
var i = 0
|
||||
fun varint(): Long { var v = 0L; var s = 0; while (true) { val c = b[i++].toInt() and 0xFF; v = v or ((c and 0x7F).toLong() shl s); s += 7; if (c < 0x80) return v } }
|
||||
while (i < b.size) {
|
||||
val key = varint()
|
||||
val field = (key ushr 3).toInt()
|
||||
val value: Any = when ((key and 7).toInt()) {
|
||||
0 -> varint()
|
||||
1 -> { var v = 0L; for (k in 0 until 8) v = v or ((b[i + k].toLong() and 0xFF) shl (8 * k)); i += 8; v }
|
||||
5 -> { var v = 0L; for (k in 0 until 4) v = v or ((b[i + k].toLong() and 0xFF) shl (8 * k)); i += 4; v }
|
||||
2 -> { val n = varint().toInt(); val v = b.copyOfRange(i, i + n); i += n; v }
|
||||
else -> throw IOException("wire type ${key and 7}")
|
||||
}
|
||||
out.getOrPut(field) { ArrayList() }.add(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private fun packed(b: ByteArray): List<Int> {
|
||||
val out = ArrayList<Int>(); var i = 0
|
||||
while (i < b.size) { var v = 0L; var s = 0; while (true) { val c = b[i++].toInt() and 0xFF; v = v or ((c and 0x7F).toLong() shl s); s += 7; if (c < 0x80) break }; out.add(v.toInt()) }
|
||||
return out
|
||||
}
|
||||
|
||||
// ---- WebSocket over TLS, binary frames ----
|
||||
|
||||
private class Wss(host: String, timeoutMs: Int) : AutoCloseable {
|
||||
// Made with the host name, so it goes out as SNI.
|
||||
private val socket: SSLSocket = (SSLSocketFactory.getDefault().createSocket(host, 443) as SSLSocket).apply { soTimeout = timeoutMs }
|
||||
private val out: OutputStream
|
||||
private val input: DataInputStream
|
||||
|
||||
init {
|
||||
// Host name checked against the certificate, as HTTPS does.
|
||||
socket.sslParameters = socket.sslParameters.apply { endpointIdentificationAlgorithm = "HTTPS" }
|
||||
socket.startHandshake()
|
||||
out = socket.outputStream
|
||||
input = DataInputStream(socket.inputStream)
|
||||
val key = ByteArray(16).also { SecureRandom().nextBytes(it) }
|
||||
out.write(("GET /cmsocket/ HTTP/1.1\r\nHost: $host\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" +
|
||||
"Sec-WebSocket-Key: ${android.util.Base64.encodeToString(key, android.util.Base64.NO_WRAP)}\r\nSec-WebSocket-Version: 13\r\n\r\n").toByteArray())
|
||||
out.flush()
|
||||
val status = line(input) ?: throw IOException("no upgrade response")
|
||||
if (!status.contains(" 101 ")) throw IOException("upgrade refused: $status")
|
||||
while (true) { val l = line(input) ?: break; if (l.isEmpty()) break }
|
||||
}
|
||||
|
||||
private fun line(s: InputStream): String? {
|
||||
val buf = ByteArrayOutputStream()
|
||||
while (true) {
|
||||
val b = s.read()
|
||||
if (b < 0) return if (buf.size() == 0) null else buf.toString("UTF-8")
|
||||
if (b == '\n'.code) return buf.toString("UTF-8").trimEnd('\r')
|
||||
buf.write(b)
|
||||
}
|
||||
}
|
||||
|
||||
fun send(payload: ByteArray) {
|
||||
val mask = ByteArray(4).also { SecureRandom().nextBytes(it) }
|
||||
val frame = ByteArrayOutputStream()
|
||||
frame.write(0x82)
|
||||
when {
|
||||
payload.size < 126 -> frame.write(0x80 or payload.size)
|
||||
payload.size < 65536 -> { frame.write(0x80 or 126); frame.write(payload.size ushr 8); frame.write(payload.size and 0xFF) }
|
||||
else -> { frame.write(0x80 or 127); for (k in 7 downTo 0) frame.write(((payload.size.toLong() ushr (8 * k)) and 0xFF).toInt()) }
|
||||
}
|
||||
frame.write(mask)
|
||||
for (k in payload.indices) frame.write((payload[k].toInt() xor mask[k % 4].toInt()) and 0xFF)
|
||||
out.write(frame.toByteArray()); out.flush()
|
||||
}
|
||||
|
||||
/** The next binary message (fragments joined); null on close. */
|
||||
fun receive(): ByteArray? {
|
||||
val message = ByteArrayOutputStream()
|
||||
while (true) {
|
||||
val b0 = input.read(); if (b0 < 0) return null
|
||||
val b1 = input.read(); if (b1 < 0) return null
|
||||
val fin = b0 and 0x80 != 0
|
||||
val opcode = b0 and 0x0F
|
||||
var len = (b1 and 0x7F).toLong()
|
||||
if (len == 126L) len = input.readUnsignedShort().toLong() else if (len == 127L) len = input.readLong()
|
||||
if (len > 32L * 1024 * 1024) throw IOException("frame too large")
|
||||
val data = ByteArray(len.toInt()).also { input.readFully(it) }
|
||||
when (opcode) {
|
||||
0x8 -> return null
|
||||
0x9 -> { out.write(byteArrayOf(0x8A.toByte(), 0x80.toByte(), 0, 0, 0, 0)); out.flush() }
|
||||
0x2, 0x0 -> { message.write(data); if (fin) return message.toByteArray() }
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
runCatching { out.write(byteArrayOf(0x88.toByte(), 0x80.toByte(), 0, 0, 0, 0)); out.flush() }
|
||||
runCatching { socket.close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.util.AtomicFile
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import com.droiddeck.launcher.stores.epic.EpicApiClient
|
||||
import com.droiddeck.launcher.stores.epic.EpicCredentialStore
|
||||
import com.droiddeck.launcher.stores.epic.EpicDownloadManager
|
||||
import com.droiddeck.launcher.stores.gog.GogAuth
|
||||
import com.droiddeck.launcher.stores.gog.GogDownloadManager
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* For a GOG or Epic game installed before its sidecar kept the store's list (GOG's build
|
||||
* `dependencies`, Epic's manifest prerequisite): the list read once from the store's build
|
||||
* manifest with the saved sign-in - the manifest alone, never a game file - and kept in the sidecar
|
||||
* the way an install keeps it. Runs on the components' worker ([AutoComponents]). Without a
|
||||
* sign-in or a network it is skipped quietly and tried again a week later.
|
||||
*/
|
||||
object StoreListBackfill {
|
||||
private const val TAG = "StoreListBackfill"
|
||||
private const val FILE = "store-list-backfill.json"
|
||||
private const val RETRY_AFTER_MS = 7L * 24 * 3600 * 1000
|
||||
|
||||
/** The store's list for the game in a sidecar: GOG's dependency ids, or Epic's prerequisite {name, path}; null when it could not be had. */
|
||||
fun interface Fetcher {
|
||||
fun fetch(context: Context, sidecar: StoreGameSidecar): Map<String, String>?
|
||||
}
|
||||
|
||||
/** The store itself, with the saved sign-in. */
|
||||
internal val storeFetcher = Fetcher { context, sidecar -> fromStore(context, sidecar) }
|
||||
|
||||
@VisibleForTesting internal var fetcher: Fetcher = storeFetcher
|
||||
|
||||
/** Whether [sidecar] was written before the list was kept. */
|
||||
/**
|
||||
* Whether [sidecar] has no list read whole: written before the list was kept, or by a build
|
||||
* that could have written an empty one for a manifest it did not read right.
|
||||
*/
|
||||
fun needs(sidecar: StoreGameSidecar): Boolean =
|
||||
(sidecar.store == Store.GOG || sidecar.store == Store.EPIC) && WinCompSources.LIST_READ !in sidecar.extra
|
||||
|
||||
/**
|
||||
* The list for the game in [folder], fetched and kept when its sidecar lacks one and it was not
|
||||
* tried in the past week. True when the sidecar changed. Blocking (network).
|
||||
*/
|
||||
@Synchronized
|
||||
fun fill(context: Context, folder: File): Boolean {
|
||||
val sidecar = StoreGameSidecar.read(folder)?.takeIf { it.isInstalled && needs(it) } ?: return false
|
||||
val tries = runCatching { JSONObject(AtomicFile(File(context.filesDir, FILE)).readFully().toString(Charsets.UTF_8)) }.getOrDefault(JSONObject())
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - tries.optLong(folder.path, 0L) < RETRY_AFTER_MS) return false
|
||||
// Offline is no try: it waits for a network rather than a week.
|
||||
if (!AutoComponents.online(context)) return false
|
||||
val list = runCatching { fetcher.fetch(context, sidecar) }.getOrNull()
|
||||
if (list == null) {
|
||||
tries.put(folder.path, now)
|
||||
val atomic = AtomicFile(File(context.filesDir, FILE))
|
||||
val out = atomic.startWrite()
|
||||
try { out.write(tries.toString().toByteArray()); atomic.finishWrite(out) } catch (e: Exception) { atomic.failWrite(out) }
|
||||
Log.i(TAG, "${sidecar.title}: the ${sidecar.store.label} list could not be had; again in a week")
|
||||
return false
|
||||
}
|
||||
// Read fresh and written whole, as an install writes it: only the list is added.
|
||||
val current = StoreGameSidecar.read(folder) ?: return false
|
||||
current.copy(extra = current.extra + list + (WinCompSources.LIST_READ to "1")).write(folder)
|
||||
Log.i(TAG, "backfill: ${sidecar.title} " + when (sidecar.store) {
|
||||
Store.GOG -> "gog deps=[${list[WinCompSources.GOG_DEPENDENCIES].orEmpty()}]"
|
||||
else -> "epic prereq=" + (list[WinCompSources.EPIC_PREREQ_NAME].orEmpty().ifEmpty { list[WinCompSources.EPIC_PREREQ_PATH].orEmpty() }.ifEmpty { "none" })
|
||||
})
|
||||
return true
|
||||
}
|
||||
|
||||
private fun fromStore(context: Context, sidecar: StoreGameSidecar): Map<String, String>? {
|
||||
return when (sidecar.store) {
|
||||
Store.GOG -> {
|
||||
val token = GogAuth.validToken(context) ?: return null
|
||||
GogDownloadManager.fetchDependencies(sidecar.id, sidecar.installVersion, token)
|
||||
?.let { mapOf(WinCompSources.GOG_DEPENDENCIES to it.joinToString(",")) }
|
||||
}
|
||||
Store.EPIC -> {
|
||||
val token = EpicCredentialStore.getValidAccessToken(context) ?: return null
|
||||
val namespace = sidecar.extra["namespace"].orEmpty()
|
||||
val catalogItemId = sidecar.extra["catalogItemId"].orEmpty()
|
||||
if (namespace.isEmpty() || catalogItemId.isEmpty()) return null
|
||||
val json = EpicApiClient.getManifestApiJson(token, namespace, catalogItemId, sidecar.extra["appName"] ?: sidecar.id) ?: return null
|
||||
EpicDownloadManager.fetchPrereq(json)?.let { (name, path) ->
|
||||
mapOf(WinCompSources.EPIC_PREREQ_NAME to name, WinCompSources.EPIC_PREREQ_PATH to path)
|
||||
}
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Which Windows components a game needs, from the list its source keeps - the source badge decides
|
||||
* which list is trusted:
|
||||
*
|
||||
* - **Steam**: the Steamworks Shared redistributables Steam installs with the game (its
|
||||
* appmanifest's SharedDepots, else the client's app info) - turned on by themselves.
|
||||
* - **GOG**: the build manifest's `dependencies`, kept in the sidecar at install - on by themselves.
|
||||
* - **Epic Games**: the manifest's prerequisite (usually UE4/UE5's prerequisite setup: VC++ and the
|
||||
* DirectX June 2010 runtime), kept in the sidecar at install - on by themselves.
|
||||
* - **Amazon Games**: fuel.json's PostInstall installers - on by themselves.
|
||||
* - **Custom** whose files name a Steam appid ([SteamMatch], certain): Steam's list for that app -
|
||||
* on by themselves. Matched by name only: the same list as Recommended. No match: the folder scan
|
||||
* ([DependencyDetector]) as Recommended.
|
||||
*
|
||||
* Every store entry is mapped to catalog names here, in one place; one that maps to nothing is a
|
||||
* suggestion under its own name. The folder scan is offered as Recommended for every game.
|
||||
*/
|
||||
object WinCompSources {
|
||||
/** Whose list a finding came from; the reason line says it. */
|
||||
enum class Origin { STEAM, GOG, EPIC, AMAZON, STEAM_NAME, FOLDER }
|
||||
|
||||
/**
|
||||
* One thing a list names: [component] the catalog name it maps to (null: none, a suggestion),
|
||||
* [original] what the list called it, [auto] whether it is turned on by itself, [args] what
|
||||
* the reason line names (Steam: the label, the game, the appid).
|
||||
*/
|
||||
data class Finding(val component: String?, val original: String, val origin: Origin, val auto: Boolean, val args: List<String> = emptyList())
|
||||
|
||||
// ---- mapping tables: a store's names -> catalog components ----
|
||||
|
||||
private val VC_YEAR = mapOf(
|
||||
"2005" to "vcredist2005", "2008" to "vcredist2008", "2010" to "vcredist2010", "2012" to "vcredist2012",
|
||||
"2013" to "vcredist2013", "2015" to "vcredist2015", "2017" to "vcredist2015", "2019" to "vcredist2019", "2022" to "vcredist2022",
|
||||
)
|
||||
private val DOTNET = mapOf(
|
||||
"35" to "dotnet35", "40" to "dotnet40", "45" to "dotnet45", "451" to "dotnet452", "452" to "dotnet452",
|
||||
"46" to "dotnet46", "461" to "dotnet46", "462" to "dotnet46", "47" to "dotnet472", "471" to "dotnet472",
|
||||
"472" to "dotnet472", "48" to "dotnet48", "481" to "dotnet48",
|
||||
)
|
||||
|
||||
/**
|
||||
* A GOG dependency id ("MSVC2017_x64", "DirectX", "DOTNET48", "PhysX", "OpenAL", "XNA4") to its
|
||||
* components; empty for one that is none of ours (ISI scripts, GOG's own helpers).
|
||||
*/
|
||||
fun gog(id: String): List<String> {
|
||||
val key = id.uppercase(Locale.US).replace(Regex("[^A-Z0-9]"), "")
|
||||
Regex("""^(?:MSVC|VCREDIST|VC)(\d{4})""").find(key)?.let { m -> return listOfNotNull(VC_YEAR[m.groupValues[1]]) }
|
||||
Regex("""^(?:DOTNET|NETFRAMEWORK|DOTNETFRAMEWORK)(\d{2,3})""").find(key)?.let { m -> return listOfNotNull(DOTNET[m.groupValues[1]]) }
|
||||
return when {
|
||||
key.startsWith("DIRECTX") || key.startsWith("DX9") || key == "DXSETUP" || key.startsWith("D3DX") -> listOf("d3dx9")
|
||||
key.startsWith("PHYSX") -> listOf("physx")
|
||||
key.startsWith("OPENAL") -> listOf("oalinst")
|
||||
key == "XNA31" || key == "XNA3" -> listOf("xna31")
|
||||
key.startsWith("XNA") -> listOf("xna40")
|
||||
key.startsWith("XLIVE") || key.startsWith("GFWL") -> listOf("XLiveRedist")
|
||||
key.startsWith("XACT") -> listOf("xact")
|
||||
else -> emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
/** Unreal's prerequisite setup (UE4PrereqSetup_x64.exe, UEPrereqSetup_x64.exe): VC++ 2015-2022 and DirectX June 2010. */
|
||||
private val UE_PREREQ = Regex("""(?i)ue\d?prereqsetup""")
|
||||
|
||||
/** An Epic prerequisite ([path] the installer it runs, [name] what it is called) to its components. */
|
||||
fun epic(path: String, name: String): List<String> = when {
|
||||
UE_PREREQ.containsMatchIn(path) || name.contains("UE4 Prerequisites", true) || name.contains("UE5 Prerequisites", true) ||
|
||||
name.contains("Unreal Engine Prerequisites", true) -> listOf("vcredist2022", "d3dx9")
|
||||
else -> DependencyDetector.componentsFor(path).ifEmpty { if (path.isEmpty()) gog(name) else emptyList() }
|
||||
}
|
||||
|
||||
/** An installer a store runs after an install (Amazon's PostInstall "Command") to its components. */
|
||||
fun installer(path: String): List<String> = if (UE_PREREQ.containsMatchIn(path)) listOf("vcredist2022", "d3dx9") else DependencyDetector.componentsFor(path)
|
||||
|
||||
/** Steamworks Shared depot ids to (component, Steam's label). */
|
||||
fun steamDepots(ids: List<String>): List<Pair<String, String>> = ids.mapNotNull { SteamRedists.DEPOTS[it] }.distinctBy { it.first }
|
||||
|
||||
// ---- what each store left at install ----
|
||||
|
||||
/** Sidecar keys the store installs fill. */
|
||||
const val GOG_DEPENDENCIES = "dependencies"
|
||||
const val EPIC_PREREQ_PATH = "prereqPath"
|
||||
const val EPIC_PREREQ_NAME = "prereqName"
|
||||
/** Set beside the list when it was read whole (an empty list then means the store lists none). */
|
||||
const val LIST_READ = "listRead"
|
||||
|
||||
/** fuel.json's PostInstall commands. */
|
||||
fun amazonPostInstall(folder: File): List<String> = runCatching {
|
||||
val arr = JSONObject(File(folder, "fuel.json").readText()).optJSONArray("PostInstall") ?: return emptyList()
|
||||
(0 until arr.length()).mapNotNull { arr.optJSONObject(it)?.optString("Command")?.takeIf { c -> c.isNotEmpty() } }
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
// ---- per game ----
|
||||
|
||||
/** Steam's list for [appId]: the local appmanifest's SharedDepots, else the client's app info. */
|
||||
fun steamList(context: Context, gameDir: File?, appId: Int, gameName: String, auto: Boolean): List<Finding> {
|
||||
val local = SteamRedists.detect(gameDir, appId)
|
||||
val pairs = if (local.isNotEmpty()) local.map { it.componentName to it.label }
|
||||
else SteamAppInfo.info(context, appId)?.let { steamDepots(it.sharedDepots) }.orEmpty()
|
||||
return pairs.map { (component, label) ->
|
||||
Finding(component, label, if (auto) Origin.STEAM else Origin.STEAM_NAME, auto, listOf(label, gameName, appId.toString()))
|
||||
}
|
||||
}
|
||||
|
||||
/** Everything known for [game], blocking (files; the client's app info; no network). */
|
||||
fun forGame(context: Context, game: Library.SteamGame): List<Finding> {
|
||||
val folder = game.gameFiles
|
||||
val out = ArrayList<Finding>()
|
||||
val store = Store.byId(game.source)
|
||||
val sidecar = folder?.let { StoreGameSidecar.read(it) }
|
||||
when {
|
||||
game.library != Library.ADDED -> if (game.appId > 0) out += steamList(context, folder, game.appId, game.name, auto = true)
|
||||
store == Store.GOG -> sidecar?.extra?.get(GOG_DEPENDENCIES)?.split(',')?.map { it.trim() }?.filter { it.isNotEmpty() }?.forEach { id ->
|
||||
val parts = gog(id)
|
||||
if (parts.isEmpty()) out += Finding(null, id, Origin.GOG, false)
|
||||
parts.forEach { out += Finding(it, id, Origin.GOG, true) }
|
||||
}
|
||||
store == Store.EPIC -> {
|
||||
val path = sidecar?.extra?.get(EPIC_PREREQ_PATH).orEmpty()
|
||||
val name = sidecar?.extra?.get(EPIC_PREREQ_NAME).orEmpty()
|
||||
if (path.isNotEmpty() || name.isNotEmpty()) {
|
||||
val parts = epic(path, name)
|
||||
val original = name.ifEmpty { path.substringAfterLast('/') }
|
||||
if (parts.isEmpty()) out += Finding(null, original, Origin.EPIC, false)
|
||||
parts.forEach { out += Finding(it, original, Origin.EPIC, true) }
|
||||
}
|
||||
}
|
||||
store == Store.AMAZON -> folder?.let { amazonPostInstall(it) }?.forEach { command ->
|
||||
val parts = installer(command)
|
||||
val original = command.replace('\\', '/').substringAfterLast('/')
|
||||
if (parts.isEmpty()) out += Finding(null, original, Origin.AMAZON, false)
|
||||
parts.forEach { out += Finding(it, original, Origin.AMAZON, true) }
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
// A game added in DroidDeck (Custom, or a store's) that is a Steam app: Steam's list for it,
|
||||
// on by itself when its files say so, else only recommended.
|
||||
if (game.library == Library.ADDED && folder != null) SteamMatch.get(context, folder.path)?.let { match ->
|
||||
val id = match.appId ?: return@let
|
||||
when (match.certainty) {
|
||||
SteamMatch.Certainty.FILES -> out += steamList(context, folder, id, game.name, auto = true)
|
||||
SteamMatch.Certainty.NAME -> out += steamList(context, folder, id, game.name, auto = false)
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
// The folder scan, for every game: what it bundles, as Recommended.
|
||||
folder?.let { DependencyDetector.detect(it) }?.forEach { rec ->
|
||||
out += Finding(rec.componentName, rec.reason, Origin.FOLDER, false, listOf(rec.reason, rec.kind.name))
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
@@ -410,6 +410,8 @@ class SessionService : Service() {
|
||||
guest.add(it)
|
||||
Log.i(TAG, "steam: handing the client $it")
|
||||
}
|
||||
// A game whose download finishes in this session gets its Windows components readied in it.
|
||||
if (SessionState.mode == MODE_STEAM) com.droiddeck.launcher.frontend.SessionInstalls.watch(this)
|
||||
// A program under gamescope: the script's run mode takes the path (an AppImage, a script
|
||||
// or a binary inside the runtime). This is how an emulator gets the GPU - the desktop's
|
||||
// KWin composites in software and offers no dma-buf, so a Vulkan swapchain cannot exist
|
||||
|
||||
@@ -77,6 +77,21 @@ object WinComponents {
|
||||
|
||||
fun validId(id: String): Boolean = ID.matches(id) && ".." !in id
|
||||
|
||||
private val BACKGROUND = ThreadLocal<Boolean>()
|
||||
|
||||
/**
|
||||
* Runs [block] as background work: the runtime helpers an install starts on this thread run
|
||||
* at the lowest CPU and I/O priority (nice 19, ionice idle where the runtime has it).
|
||||
*/
|
||||
fun <T> background(block: () -> T): T {
|
||||
BACKGROUND.set(true)
|
||||
try { return block() } finally { BACKGROUND.remove() }
|
||||
}
|
||||
|
||||
private fun guest(argv: List<String>): List<String> = if (BACKGROUND.get() != true) argv else listOf(
|
||||
"/bin/sh", "-c", "if command -v ionice >/dev/null 2>&1; then exec nice -n 19 ionice -c3 \"\$@\"; else exec nice -n 19 \"\$@\"; fi", "sh",
|
||||
) + argv
|
||||
|
||||
class Step(val action: String, val json: JSONObject) {
|
||||
fun str(key: String): String = json.optString(key, "").ifEmpty {
|
||||
if (key in NESTED_KEYS) json.optJSONObject("environment")?.optString(key, "").orEmpty() else ""
|
||||
@@ -133,8 +148,24 @@ object WinComponents {
|
||||
/** The steps a recording makes redundant: everything that places files or edits the registry. */
|
||||
private val SNAPSHOT_COVERS = INSTALLER_STEPS + setOf("download_archive", "archive_extract", "copy_dll", "copy_file") + OFFLINE_STEPS
|
||||
|
||||
fun fetch(): List<Component>? {
|
||||
val body = Downloader.downloadString(CATALOG_URL) ?: return null
|
||||
fun fetch(): List<Component>? = Downloader.downloadString(CATALOG_URL)?.let { parse(it) }
|
||||
|
||||
private const val CATALOG_CACHE = "wincomponents-catalog.json"
|
||||
|
||||
/** The catalog from the network, kept for later; the copy kept last time when offline. */
|
||||
fun fetch(context: Context): List<Component>? {
|
||||
val cache = File(context.filesDir, CATALOG_CACHE)
|
||||
Downloader.downloadString(CATALOG_URL)?.let { body ->
|
||||
parse(body)?.let { runCatching { write(cache, body) }; return it }
|
||||
}
|
||||
return runCatching { parse(cache.readText()) }.getOrNull()
|
||||
}
|
||||
|
||||
/** The copy of the catalog kept by [fetch], without asking the network; null before the first fetch. */
|
||||
fun cached(context: Context): List<Component>? =
|
||||
runCatching { parse(File(context.filesDir, CATALOG_CACHE).readText()) }.getOrNull()
|
||||
|
||||
private fun parse(body: String): List<Component>? {
|
||||
return try {
|
||||
val arr = JSONObject(body).getJSONArray("components")
|
||||
(0 until arr.length()).mapNotNull { i ->
|
||||
@@ -155,6 +186,16 @@ object WinComponents {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The catalog entry that installs [name] here: the detector names installers
|
||||
* (vcredist2022, oalinst), and their DLL-copy twin (vcredist2022_dll, oalinst_dll) is the one
|
||||
* that installs without running anything; [name] itself when there is no such twin.
|
||||
*/
|
||||
fun installable(name: String, all: Map<String, Component>): String {
|
||||
val twin = all[if (name == "oalinst") "oalinst_dll" else "${name}_dll"]
|
||||
return if (twin != null && support(twin, all) == Support.READY) twin.name else name
|
||||
}
|
||||
|
||||
/** Whether [c] can be installed here, its bundled components included. */
|
||||
fun support(c: Component, all: Map<String, Component>, depth: Int = 0): Support {
|
||||
if (c.status != "ready" || depth > 8 || protonProvides(c.name)) return Support.UNSUPPORTED
|
||||
@@ -506,7 +547,7 @@ object WinComponents {
|
||||
val inside = archive.relativeTo(root).path
|
||||
var problem: String? = null
|
||||
var ok = false
|
||||
val status = GuestCommand.run(context, listOf(MSI_INSTALL, "--unpack", "/$inside", "/$MSI_CACHE/${dir.name}"),
|
||||
val status = GuestCommand.run(context, guest(listOf(MSI_INSTALL, "--unpack", "/$inside", "/$MSI_CACHE/${dir.name}")),
|
||||
logName = "wincomponents-${c.name}") { line ->
|
||||
when {
|
||||
line.startsWith("progress ") -> engineLine(line.removePrefix("progress ")).let { (text, _) -> onProgress(Progress(c.name, text, Phase.INSTALL, -1)) }
|
||||
@@ -545,7 +586,7 @@ object WinComponents {
|
||||
onProgress(Progress(c.name, "installing", Phase.INSTALL, 0))
|
||||
var result: JSONObject? = null
|
||||
var problem: String? = null
|
||||
val status = GuestCommand.run(context, listOf(MSI_INSTALL, "/$MSI_CACHE/${file.name}", "/$STORE/${staging.name}"),
|
||||
val status = GuestCommand.run(context, guest(listOf(MSI_INSTALL, "/$MSI_CACHE/${file.name}", "/$STORE/${staging.name}")),
|
||||
logName = "wincomponents-${c.name}") { line ->
|
||||
when {
|
||||
line.startsWith("progress ") -> engineLine(line.removePrefix("progress ")).let { (text, pct) -> onProgress(Progress(c.name, text, Phase.INSTALL, pct)) }
|
||||
@@ -622,36 +663,101 @@ object WinComponents {
|
||||
fun uninstall(context: Context, id: String) {
|
||||
require(validId(id))
|
||||
FileUtils.delete(File(LinuxRuntime.rootDir(context), "$STORE/$id"))
|
||||
save(context, read(context).mapValues { (_, ids) -> ids - id }.filterValues { it.isNotEmpty() })
|
||||
save(context, selections(context).mapValues { (_, sel) -> Selection(sel.auto - id, sel.user - id) })
|
||||
}
|
||||
|
||||
/**
|
||||
* Why a component was turned on for a game by itself ([setAuto]): [kind] says whose list it came
|
||||
* from (see AutoComponents), [args] what the reason line names. Kept beside the picks.
|
||||
*/
|
||||
class AutoReason(val kind: String, val args: List<String>)
|
||||
|
||||
/**
|
||||
* Everything kept per game, by the appid its prefix is named after: [auto] - turned on by
|
||||
* itself, with its reason; [user] - a switch the user pressed (true on, false off), which wins
|
||||
* over [auto] either way. What the launch reads is [effective].
|
||||
*/
|
||||
class Selection(val auto: Map<String, AutoReason>, val user: Map<String, Boolean>) {
|
||||
val effective: List<String>
|
||||
get() = (auto.keys.filter { user[it] != false } + user.filterValues { it }.keys).distinct()
|
||||
}
|
||||
|
||||
/** Each game's picks, by the appid its prefix is named after. */
|
||||
@Synchronized
|
||||
fun read(context: Context): Map<String, List<String>> = try {
|
||||
fun selections(context: Context): Map<String, Selection> = try {
|
||||
val json = JSONObject(AtomicFile(File(context.filesDir, SELECTION)).readFully().toString(Charsets.UTF_8))
|
||||
val games = json.optJSONObject("games") ?: JSONObject()
|
||||
games.keys().asSequence().associateWith { app ->
|
||||
val ids = games.getJSONArray(app)
|
||||
(0 until ids.length()).map { ids.getString(it) }.filter { validId(it) }
|
||||
val autos = json.optJSONObject("auto") ?: JSONObject()
|
||||
val users = json.optJSONObject("user")
|
||||
val keys = games.keys().asSequence().toSet() + autos.keys().asSequence().toSet() + (users?.keys()?.asSequence()?.toSet() ?: emptySet())
|
||||
keys.associateWith { app ->
|
||||
val auto = autos.optJSONObject(app)?.let { o ->
|
||||
o.keys().asSequence().filter { validId(it) }.associateWith { id ->
|
||||
val r = o.optJSONObject(id) ?: JSONObject()
|
||||
val a = r.optJSONArray("args") ?: JSONArray()
|
||||
AutoReason(r.optString("kind"), (0 until a.length()).map { a.optString(it) })
|
||||
}
|
||||
}.orEmpty()
|
||||
// Before there were overrides, every pick was the user's own.
|
||||
val user = if (users == null) games.optJSONArray(app)?.let { ids -> (0 until ids.length()).map { ids.getString(it) }.filter { validId(it) }.associateWith { true } }.orEmpty()
|
||||
else users.optJSONObject(app)?.let { o -> o.keys().asSequence().filter { validId(it) }.associateWith { o.optBoolean(it) } }.orEmpty()
|
||||
Selection(auto, user)
|
||||
}
|
||||
} catch (_: java.io.FileNotFoundException) {
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
fun picks(context: Context, appKey: String): List<String> = read(context)[appKey].orEmpty()
|
||||
/** Each game's picks as the launch takes them, by the appid its prefix is named after. */
|
||||
fun read(context: Context): Map<String, List<String>> =
|
||||
selections(context).mapValues { it.value.effective }.filterValues { it.isNotEmpty() }
|
||||
|
||||
fun picks(context: Context, appKey: String): List<String> = selections(context)[appKey]?.effective.orEmpty()
|
||||
|
||||
fun selection(context: Context, appKey: String): Selection = selections(context)[appKey] ?: Selection(emptyMap(), emptyMap())
|
||||
|
||||
/** The user's own switch for [id] in [appKey]: on or off, over whatever was picked by itself. */
|
||||
@Synchronized
|
||||
fun setPicks(context: Context, appKey: String, ids: List<String>) {
|
||||
require(ids.all { validId(it) })
|
||||
val all = read(context).toMutableMap()
|
||||
if (ids.isEmpty()) all.remove(appKey) else all[appKey] = ids.distinct()
|
||||
fun setUser(context: Context, appKey: String, id: String, on: Boolean) {
|
||||
require(validId(id))
|
||||
val all = selections(context).toMutableMap()
|
||||
val now = all[appKey] ?: Selection(emptyMap(), emptyMap())
|
||||
all[appKey] = Selection(now.auto, now.user + (id to on))
|
||||
save(context, all)
|
||||
}
|
||||
|
||||
private fun save(context: Context, games: Map<String, List<String>>) {
|
||||
val text = JSONObject().put("version", 1).put("games", JSONObject().apply {
|
||||
games.forEach { (app, ids) -> put(app, JSONArray(ids)) }
|
||||
}).toString()
|
||||
/** The components picked by themselves for [appKey], replacing the earlier ones; the user's switches stay. */
|
||||
@Synchronized
|
||||
fun setAuto(context: Context, appKey: String, auto: Map<String, AutoReason>) {
|
||||
require(auto.keys.all { validId(it) })
|
||||
val all = selections(context).toMutableMap()
|
||||
val now = all[appKey] ?: Selection(emptyMap(), emptyMap())
|
||||
all[appKey] = Selection(auto, now.user)
|
||||
save(context, all)
|
||||
}
|
||||
|
||||
/** The user's picks for [appKey] set outright (each listed on, every other user switch dropped). */
|
||||
@Synchronized
|
||||
fun setPicks(context: Context, appKey: String, ids: List<String>) {
|
||||
require(ids.all { validId(it) })
|
||||
val all = selections(context).toMutableMap()
|
||||
val now = all[appKey] ?: Selection(emptyMap(), emptyMap())
|
||||
all[appKey] = Selection(now.auto, ids.distinct().associateWith { true } + now.auto.keys.filter { it !in ids }.associateWith { false })
|
||||
save(context, all)
|
||||
}
|
||||
|
||||
private fun save(context: Context, games: Map<String, Selection>) {
|
||||
val kept = games.filterValues { it.auto.isNotEmpty() || it.user.isNotEmpty() }
|
||||
// Version 1 still: droiddeck-wincomponents reads "games" and ignores the rest.
|
||||
val text = JSONObject().put("version", 1)
|
||||
.put("games", JSONObject().apply { kept.forEach { (app, sel) -> sel.effective.takeIf { it.isNotEmpty() }?.let { put(app, JSONArray(it)) } } })
|
||||
.put("auto", JSONObject().apply {
|
||||
kept.forEach { (app, sel) ->
|
||||
if (sel.auto.isNotEmpty()) put(app, JSONObject().apply {
|
||||
sel.auto.forEach { (id, r) -> put(id, JSONObject().put("kind", r.kind).put("args", JSONArray(r.args))) }
|
||||
})
|
||||
}
|
||||
})
|
||||
.put("user", JSONObject().apply { kept.forEach { (app, sel) -> if (sel.user.isNotEmpty()) put(app, JSONObject(sel.user as Map<*, *>)) } })
|
||||
.toString()
|
||||
write(File(context.filesDir, SELECTION), text)
|
||||
publish(context, text)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ object StoreInstalls {
|
||||
try { StoreArt.fetchInto(folder, finished) } catch (e: Exception) { Log.w(TAG, "art for ${folder.name}: ${e.message}") }
|
||||
onStep?.invoke(2, STEPS)
|
||||
register(app)
|
||||
// Its Windows components from the store's list (GOG's dependencies, Epic's prerequisite,
|
||||
// Amazon's install steps), on a thread of their own.
|
||||
AddedGames.single(app, folder.path)?.let { com.droiddeck.launcher.frontend.AutoComponents.refreshLater(app, Library.addedGame(app, it)) }
|
||||
onStep?.invoke(STEPS, STEPS)
|
||||
}
|
||||
|
||||
|
||||
@@ -156,7 +156,11 @@ object EpicBackend : StoreBackend, EpicLaunchSupport {
|
||||
Store.EPIC, item.id, item.title, exe = exe.ifEmpty { "game.exe" }, args = args,
|
||||
installVersion = result.buildVersion, installedAt = System.currentTimeMillis(),
|
||||
cover = item.tallImageUrl ?: item.imageUrl, hero = item.imageUrl,
|
||||
extra = EpicLaunchData.extras(item.id, namespace, catalogItemId, deploymentId),
|
||||
// Both kept even when empty: the manifest was read, and names no prerequisite.
|
||||
extra = EpicLaunchData.extras(item.id, namespace, catalogItemId, deploymentId) + mapOf(
|
||||
com.droiddeck.launcher.frontend.WinCompSources.EPIC_PREREQ_PATH to result.prereqPath,
|
||||
com.droiddeck.launcher.frontend.WinCompSources.EPIC_PREREQ_NAME to result.prereqName,
|
||||
) + (if (result.prereqRead) mapOf(com.droiddeck.launcher.frontend.WinCompSources.LIST_READ to "1") else emptyMap()),
|
||||
)
|
||||
StoreInstalls.complete(app, folder, sidecar)
|
||||
return folder.path
|
||||
|
||||
@@ -107,6 +107,11 @@ public final class EpicDownloadManager {
|
||||
public String launchExe = "";
|
||||
public String launchCommand = "";
|
||||
public String buildVersion = "";
|
||||
/** The prerequisite the launcher runs first (UE4PrereqSetup_x64.exe...): what it is called and its path in the install. */
|
||||
public String prereqName = "";
|
||||
public String prereqPath = "";
|
||||
/** The prerequisite fields were read (an empty name and path then mean the build names none). */
|
||||
public boolean prereqRead = false;
|
||||
public List<CdnUrl> cdnUrls = new ArrayList<>();
|
||||
}
|
||||
|
||||
@@ -115,7 +120,12 @@ public final class EpicDownloadManager {
|
||||
public final String launchExe;
|
||||
public final String buildVersion;
|
||||
public final long bytes;
|
||||
/** The manifest's prerequisite, for Windows components; "" when it names none. */
|
||||
public String prereqName = "";
|
||||
public String prereqPath = "";
|
||||
Result(String launchExe, String buildVersion, long bytes) { this.launchExe = launchExe; this.buildVersion = buildVersion; this.bytes = bytes; }
|
||||
public boolean prereqRead = false;
|
||||
Result withPrereq(Manifest m) { prereqName = m.prereqName; prereqPath = m.prereqPath; prereqRead = m.prereqRead; return this; }
|
||||
}
|
||||
|
||||
public static final class InstallException extends Exception {
|
||||
@@ -178,7 +188,7 @@ public final class EpicDownloadManager {
|
||||
if (pending.isEmpty()) {
|
||||
deleteDir(chunkCacheDir);
|
||||
cb.onProgress("Complete", 100);
|
||||
return new Result(manifest.launchExe, manifest.buildVersion, planned);
|
||||
return new Result(manifest.launchExe, manifest.buildVersion, planned).withPrereq(manifest);
|
||||
}
|
||||
List<ChunkInfo> needed = uniqueChunksForFiles(manifest, pending);
|
||||
// The cache holds whole chunk windows (~1 MiB each), and a window is shared with files
|
||||
@@ -265,7 +275,7 @@ public final class EpicDownloadManager {
|
||||
if (r.cancelled) return null;
|
||||
if (!r.success) throw new InstallException(r.error.isEmpty() ? "assembly failed" : r.error);
|
||||
cb.onProgress("Complete", 100);
|
||||
return new Result(manifest.launchExe, manifest.buildVersion, planned);
|
||||
return new Result(manifest.launchExe, manifest.buildVersion, planned).withPrereq(manifest);
|
||||
}
|
||||
cb.onLog("epic: assembler not started (" + r.error + "); writing the files here");
|
||||
}
|
||||
@@ -293,7 +303,7 @@ public final class EpicDownloadManager {
|
||||
}
|
||||
deleteDir(chunkCacheDir);
|
||||
cb.onProgress("Complete", 100);
|
||||
return new Result(manifest.launchExe, manifest.buildVersion, planned);
|
||||
return new Result(manifest.launchExe, manifest.buildVersion, planned).withPrereq(manifest);
|
||||
} catch (InstallException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
@@ -470,6 +480,13 @@ public final class EpicDownloadManager {
|
||||
m.buildVersion = readFString(b);
|
||||
m.launchExe = readFString(b).replace('\\', '/');
|
||||
m.launchCommand = readFString(b);
|
||||
// PrereqIds (an array), PrereqName, PrereqPath, PrereqArgs.
|
||||
int prereqIds = b.getInt();
|
||||
for (int i = 0; i < prereqIds && i < 64; i++) readFString(b);
|
||||
m.prereqName = readFString(b);
|
||||
m.prereqPath = readFString(b).replace('\\', '/');
|
||||
readFString(b); // PrereqArgs
|
||||
m.prereqRead = true;
|
||||
} catch (Exception e) {
|
||||
Log.w(TAG, "manifest meta: " + e.getClass().getSimpleName());
|
||||
}
|
||||
@@ -537,6 +554,9 @@ public final class EpicDownloadManager {
|
||||
m.launchExe = root.optString("LaunchExe", "").replace('\\', '/');
|
||||
m.launchCommand = root.optString("LaunchCommand", "");
|
||||
m.buildVersion = root.optString("BuildVersionString", "");
|
||||
m.prereqName = root.optString("PrereqName", "");
|
||||
m.prereqPath = root.optString("PrereqPath", "").replace('\\', '/');
|
||||
m.prereqRead = true;
|
||||
JSONObject chunkHashList = root.optJSONObject("ChunkHashList");
|
||||
JSONObject dataGroupList = root.optJSONObject("DataGroupList");
|
||||
JSONObject chunkFilesizeList = root.optJSONObject("ChunkFilesizeList");
|
||||
@@ -753,6 +773,22 @@ public final class EpicDownloadManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** The manifest's prerequisite as {name, path}, from the manifest alone; null on failure. Blocking. */
|
||||
public static String[] fetchPrereq(String manifestApiJson) {
|
||||
try {
|
||||
List<CdnUrl> cdnUrls = parseCdnUrls(manifestApiJson);
|
||||
if (cdnUrls.isEmpty()) return null;
|
||||
byte[] bytes = downloadManifest(manifestApiJson, cdnUrls);
|
||||
if (bytes == null) return null;
|
||||
Manifest m = parseManifest(bytes);
|
||||
if (m == null || !m.prereqRead) { Log.w(TAG, "manifest prerequisite not read"); return null; }
|
||||
Log.i(TAG, "manifest " + m.buildVersion + ": " + m.files.size() + " files, prerequisite " + (m.prereqName.isEmpty() && m.prereqPath.isEmpty() ? "none" : m.prereqName + " (" + m.prereqPath + ")"));
|
||||
return new String[] { m.prereqName, m.prereqPath };
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The install size for the tags (null = every file), from the manifest alone; -1 on failure. Blocking. */
|
||||
public static long fetchInstallSizeBytes(String manifestApiJson, List<String> installTags) {
|
||||
try {
|
||||
|
||||
@@ -111,6 +111,9 @@ object GogBackend : StoreBackend {
|
||||
Store.GOG, game.gameId, game.title, exe = result.exeRelative.ifEmpty { "" },
|
||||
installVersion = result.buildId, installedAt = System.currentTimeMillis(),
|
||||
cover = item.tallImageUrl ?: item.imageUrl, hero = item.imageUrl,
|
||||
// Kept even when empty: the build was read, and lists none.
|
||||
extra = mapOf(com.droiddeck.launcher.frontend.WinCompSources.GOG_DEPENDENCIES to result.dependencies.joinToString(","))
|
||||
+ (if (result.dependenciesRead) mapOf(com.droiddeck.launcher.frontend.WinCompSources.LIST_READ to "1") else emptyMap()),
|
||||
)
|
||||
if (sidecar.exe.isEmpty()) handle.log("gog: no exe found in ${folder.name}; pick one in Steam settings › Added games")
|
||||
StoreInstalls.complete(app, folder, onStep = { done, steps -> handle.stageProgress(done.toLong(), steps.toLong()) }, sidecar = if (sidecar.exe.isEmpty()) sidecar.copy(exe = "game.exe") else sidecar)
|
||||
|
||||
@@ -79,6 +79,10 @@ public final class GogDownloadManager {
|
||||
public final String exeRelative;
|
||||
public final String buildId;
|
||||
public final long bytes;
|
||||
/** The build manifest's "dependencies" (GOG's redistributable ids: MSVC2017_x64, DirectX...), for Windows components. */
|
||||
public List<String> dependencies = new ArrayList<>();
|
||||
/** The dependencies come from a gen2 build manifest read whole (gen1 builds and installers have none). */
|
||||
public boolean dependenciesRead = false;
|
||||
Result(String exeRelative, String buildId, long bytes) { this.exeRelative = exeRelative; this.buildId = buildId; this.bytes = bytes; }
|
||||
}
|
||||
|
||||
@@ -150,10 +154,12 @@ public final class GogDownloadManager {
|
||||
if ((baseProductId == null || baseProductId.isEmpty()) && products != null && products.length() > 0)
|
||||
baseProductId = products.getJSONObject(0).optString("productId", null);
|
||||
if (baseProductId == null || baseProductId.isEmpty()) baseProductId = game.gameId;
|
||||
// The build's required redistributables (MSVC, DirectX, ...) are recorded for the engine
|
||||
// log; Steam's shared redistributables run in the prefix on first launch as for any shortcut.
|
||||
// The build's required redistributables (MSVC, DirectX, ...): logged, and kept in the
|
||||
// sidecar, where Windows components take them as this game's list (WinCompSources).
|
||||
JSONArray deps = manifest.optJSONArray("dependencies");
|
||||
if (deps != null && deps.length() > 0) cb.onLog("gog: build lists dependencies " + deps);
|
||||
List<String> dependencies = new ArrayList<>();
|
||||
if (deps != null) for (int i = 0; i < deps.length(); i++) { String d = deps.optString(i, ""); if (!d.isEmpty()) dependencies.add(d); }
|
||||
if (!dependencies.isEmpty()) cb.onLog("gog: build lists dependencies " + deps);
|
||||
|
||||
cb.onProgress("Reading depot manifests…", 10);
|
||||
List<DepotFile> files = new ArrayList<>();
|
||||
@@ -262,6 +268,8 @@ public final class GogDownloadManager {
|
||||
String clientSecret = manifest.optString("clientSecret", null);
|
||||
if (clientSecret != null && !clientSecret.isEmpty()) GogPrefs.get(ctx).edit().putString("client_secret_" + game.gameId, clientSecret).apply();
|
||||
out.result = new Result(pickExe(installPath, tempExe, game.title), gm.buildId, planned);
|
||||
out.result.dependencies = dependencies;
|
||||
out.result.dependenciesRead = true;
|
||||
return out;
|
||||
} catch (Exception e) {
|
||||
out.error = "exception: " + e.getClass().getSimpleName() + (e.getMessage() != null ? ": " + e.getMessage() : "");
|
||||
@@ -1031,6 +1039,48 @@ public final class GogDownloadManager {
|
||||
}
|
||||
|
||||
/** The install size of the Windows build (English / all-language depots), or -1. Blocking. */
|
||||
/**
|
||||
* The "dependencies" of the build [buildId] of [gameId] (else the newest Windows build), from
|
||||
* its build manifest alone: no game file is fetched. Null when the builds or the manifest
|
||||
* could not be had; empty when the build lists none. Blocking.
|
||||
*/
|
||||
public static List<String> fetchDependencies(String gameId, String buildId, String token) {
|
||||
try {
|
||||
String buildsUrl = "https://content-system.gog.com/products/" + gameId + "/os/windows/builds?generation=2";
|
||||
String buildsJson = httpGet(buildsUrl, null);
|
||||
if (buildsJson == null) buildsJson = httpGet(buildsUrl, token);
|
||||
if (buildsJson == null) return null;
|
||||
JSONArray items = new JSONObject(buildsJson).optJSONArray("items");
|
||||
if (items == null || items.length() == 0) return null;
|
||||
String manifestUrl = null;
|
||||
for (int i = 0; i < items.length(); i++) {
|
||||
JSONObject item = items.getJSONObject(i);
|
||||
if (!"windows".equals(item.optString("os"))) continue;
|
||||
String link = item.optString("link");
|
||||
if (link == null || link.isEmpty()) link = item.optString("meta_url");
|
||||
if (manifestUrl == null) manifestUrl = link;
|
||||
if (buildId != null && !buildId.isEmpty() && buildId.equals(item.optString("build_id"))) { manifestUrl = link; break; }
|
||||
}
|
||||
if (manifestUrl == null || manifestUrl.isEmpty()) return null;
|
||||
byte[] raw = fetchBytes(manifestUrl, token);
|
||||
if (raw == null) return null;
|
||||
String manifestStr = decompressBytes(raw);
|
||||
if (manifestStr == null) return null;
|
||||
JSONObject manifest = new JSONObject(manifestStr);
|
||||
// A manifest without depots is not one this reads right: no list rather than an empty one.
|
||||
JSONArray depots = manifest.optJSONArray("depots");
|
||||
if (depots == null) { Log.w(TAG, "gog: build manifest for " + gameId + " has no depots; keys " + manifest.names()); return null; }
|
||||
JSONArray deps = manifest.optJSONArray("dependencies");
|
||||
List<String> out = new ArrayList<>();
|
||||
if (deps != null) for (int i = 0; i < deps.length(); i++) { String d = deps.optString(i, ""); if (!d.isEmpty()) out.add(d); }
|
||||
Log.i(TAG, "gog: build " + manifest.optString("buildId", buildId) + " of " + gameId + ": " + depots.length() + " depots, dependencies "
|
||||
+ (manifest.has("dependencies") ? String.valueOf(deps) : "(none listed)"));
|
||||
return out;
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static long fetchInstallSizeBytes(String gameId, String token) {
|
||||
try {
|
||||
String buildsUrl = "https://content-system.gog.com/products/" + gameId + "/os/windows/builds?generation=2";
|
||||
|
||||
@@ -322,8 +322,18 @@ private fun GameList(
|
||||
modifier = modifier.verticalScroll(rememberScrollState()).padding(start = 12.dp, end = 10.dp, top = 16.dp, bottom = 16.dp),
|
||||
) {
|
||||
GamesHeader(games.size, onAdd, Modifier.padding(start = 6.dp, bottom = 10.dp))
|
||||
// A game that arrives while the list is up (Steam installed it in the session that just
|
||||
// ended, or it was added) eases in; the ones there when the page opened are simply there.
|
||||
val seen = remember { HashSet<Int>() }
|
||||
var settled by remember { androidx.compose.runtime.mutableStateOf(false) }
|
||||
for (g in games) key(g.appId) {
|
||||
GameRow(g, g.appId == current.appId, onSelect = { onSelect(g) }, onLaunch = { onLaunch(g) })
|
||||
Box(Modifier.arriveIn(settled && g.appId !in seen)) {
|
||||
GameRow(g, g.appId == current.appId, onSelect = { onSelect(g) }, onLaunch = { onLaunch(g) })
|
||||
}
|
||||
}
|
||||
androidx.compose.runtime.SideEffect {
|
||||
seen.addAll(games.map { it.appId })
|
||||
if (games.isNotEmpty()) settled = true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -442,6 +452,14 @@ private fun AddGameButton(onClick: () -> Unit) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Fades and slides a row in once when [arriving]; nothing for one already there (or with animations off). */
|
||||
@Composable
|
||||
internal fun Modifier.arriveIn(arriving: Boolean): Modifier {
|
||||
val t = remember { androidx.compose.animation.core.Animatable(if (arriving) 0f else 1f) }
|
||||
LaunchedEffect(Unit) { if (t.value < 1f) t.animateTo(1f, Motion.tw(280)) }
|
||||
return graphicsLayer { alpha = t.value; translationY = (1f - t.value) * 12.dp.toPx() }
|
||||
}
|
||||
|
||||
/** One game in the list. Moving onto it with the pad shows it; A launches it, a tap only shows it. */
|
||||
@Composable
|
||||
private fun GameRow(g: Library.SteamGame, selected: Boolean, onSelect: () -> Unit, onLaunch: () -> Unit) {
|
||||
|
||||
@@ -510,6 +510,7 @@ internal fun LaunchSettings(s: FrontEndState, a: FrontEndActions, host: MenuHost
|
||||
if (opened != null && wincompKey != null && game != null) WinComponentsDialog(
|
||||
wincompKey, game.name, game.gameFiles, opened, compat = game.protonPrefix,
|
||||
steamAppId = game.appId.takeIf { game.library != com.droiddeck.launcher.frontend.Library.ADDED },
|
||||
game = game,
|
||||
) { wincompOpen = null }
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp), modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) {
|
||||
for (row in cards.chunked(columns)) {
|
||||
|
||||
@@ -15,6 +15,10 @@ import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -38,6 +42,7 @@ import com.droiddeck.launcher.R
|
||||
import com.droiddeck.launcher.frontend.DependencyDetector
|
||||
import com.droiddeck.launcher.frontend.PrefixInstalledDetector
|
||||
import com.droiddeck.launcher.frontend.SteamRedists
|
||||
import com.droiddeck.launcher.frontend.WinCompSources
|
||||
import com.droiddeck.launcher.session.WinComponentNames
|
||||
import com.droiddeck.launcher.session.WinComponents
|
||||
import com.droiddeck.launcher.session.WinComponents.Support
|
||||
@@ -53,13 +58,38 @@ import java.io.File
|
||||
// picked ones into the game's prefix (droiddeck-wincomponents).
|
||||
|
||||
/** Bannerlator's detector names installers; here the DLL-copy twin is the one that installs. */
|
||||
private fun installable(name: String, all: Map<String, WinComponents.Component>): String {
|
||||
val dll = when (name) {
|
||||
"oalinst" -> "oalinst_dll"
|
||||
else -> "${name}_dll"
|
||||
}
|
||||
val twin = all[dll]
|
||||
return if (twin != null && WinComponents.support(twin, all) == Support.READY) dll else name
|
||||
private fun installable(name: String, all: Map<String, WinComponents.Component>): String = WinComponents.installable(name, all)
|
||||
|
||||
/** A store as a list's owner names it in a reason line. */
|
||||
@Composable
|
||||
private fun originName(origin: String): String = stringResource(
|
||||
when (origin) {
|
||||
WinCompSources.Origin.GOG.name -> R.string.wincomp_src_gog
|
||||
WinCompSources.Origin.EPIC.name -> R.string.wincomp_src_epic
|
||||
WinCompSources.Origin.AMAZON.name -> R.string.wincomp_src_amazon
|
||||
else -> R.string.wincomp_src_steam
|
||||
},
|
||||
)
|
||||
|
||||
/** Why a component was turned on by itself, from [WinComponents.AutoReason] (args: what the list called it, then the origin's own). */
|
||||
@Composable
|
||||
private fun autoReason(r: WinComponents.AutoReason): String = when (r.kind) {
|
||||
WinCompSources.Origin.STEAM.name -> stringResource(R.string.wincomp_auto_steam, r.args.getOrElse(1) { "" }, r.args.getOrElse(2) { "" }, r.args.getOrElse(3) { "" })
|
||||
WinCompSources.Origin.GOG.name -> stringResource(R.string.wincomp_auto_gog)
|
||||
WinCompSources.Origin.EPIC.name -> stringResource(R.string.wincomp_auto_epic)
|
||||
WinCompSources.Origin.AMAZON.name -> stringResource(R.string.wincomp_auto_amazon)
|
||||
else -> ""
|
||||
}
|
||||
|
||||
/** The reason line of a finding that is only recommended. */
|
||||
@Composable
|
||||
private fun findingReason(f: WinCompSources.Finding): String = when (f.origin) {
|
||||
WinCompSources.Origin.FOLDER -> stringResource(
|
||||
if (f.args.getOrNull(1) == DependencyDetector.Kind.SHIPPED.name) R.string.wincomp_found_shipped else R.string.wincomp_found_bundled,
|
||||
f.args.getOrElse(0) { f.original },
|
||||
)
|
||||
WinCompSources.Origin.STEAM_NAME -> stringResource(R.string.wincomp_steam_by_name, f.args.getOrElse(0) { f.original }, f.args.getOrElse(2) { "" })
|
||||
else -> autoReason(WinComponents.AutoReason(f.origin.name, listOf(f.original) + f.args))
|
||||
}
|
||||
|
||||
@OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
|
||||
@@ -68,6 +98,8 @@ internal fun WinComponentsDialog(
|
||||
appKey: String, gameName: String, gameDir: File?, byPad: Boolean,
|
||||
/** compatdata/<id>: what the prefix already has. [steamAppId]: a Steam title, whose appmanifest lists Steam's own redists. */
|
||||
compat: File? = null, steamAppId: Int? = null,
|
||||
/** The game as the Games tab has it: its source decides whose list is trusted ([WinCompSources]). */
|
||||
game: com.droiddeck.launcher.frontend.Library.SteamGame? = null,
|
||||
onClose: () -> Unit,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
@@ -77,11 +109,17 @@ internal fun WinComponentsDialog(
|
||||
// null while the catalog loads; empty when it could not be had.
|
||||
var catalog by remember { mutableStateOf<Map<String, WinComponents.Component>?>(null) }
|
||||
var offline by remember { mutableStateOf(false) }
|
||||
var recommended by remember { mutableStateOf(emptyList<DependencyDetector.Recommendation>()) }
|
||||
var findings by remember { mutableStateOf(emptyList<WinCompSources.Finding>()) }
|
||||
// Whether the game's list has been worked out at least once (kept from an earlier time counts).
|
||||
var findingsKnown by remember { mutableStateOf(game == null) }
|
||||
// "Loading the components" only when the catalog takes a while.
|
||||
var slowCatalog by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) { kotlinx.coroutines.delay(300); slowCatalog = true }
|
||||
var installed by remember { mutableStateOf(emptySet<String>()) }
|
||||
// Detector names (oalinst, physx...) already in the prefix from elsewhere.
|
||||
var present by remember { mutableStateOf(emptySet<String>()) }
|
||||
var picks by remember { mutableStateOf(WinComponents.picks(context, appKey)) }
|
||||
var selection by remember { mutableStateOf(WinComponents.selection(context, appKey)) }
|
||||
val picks = selection.effective
|
||||
// The row being worked on: its id, the latest progress (a dependency's while that installs
|
||||
// first), and whether it is turning off rather than on. The row greys out and shows the bar
|
||||
// the moment the switch is pressed, and only that row; the rest of the page stays as it is.
|
||||
@@ -96,6 +134,7 @@ internal fun WinComponentsDialog(
|
||||
// pad, so the d-pad always has somewhere to start; LB and RB jump between the sections, since
|
||||
// the full list runs past sixty switches.
|
||||
val recFocus = remember { FocusRequester() }
|
||||
val recFocusAfterAuto = remember { FocusRequester() }
|
||||
val activeFocus = remember { FocusRequester() }
|
||||
val allFocus = remember { FocusRequester() }
|
||||
val waitFocus = remember { FocusRequester() }
|
||||
@@ -105,20 +144,46 @@ internal fun WinComponentsDialog(
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
val (entries, found) = withContext(Dispatchers.IO) {
|
||||
// The folder's own installers first, then what Steam installs with the game.
|
||||
val own = gameDir?.let { DependencyDetector.detect(it) } ?: emptyList()
|
||||
val steam = steamAppId?.let { SteamRedists.detect(gameDir, it) } ?: emptyList()
|
||||
WinComponents.fetch() to (own + steam)
|
||||
val list = WinComponents.fetch(context)
|
||||
// The source's list first (and what it turns on by itself, already installed), then the folder scan.
|
||||
if (game != null) {
|
||||
// The list kept from last time, at once; it is worked out again (and its components
|
||||
// queued) on the background worker, which this page then follows.
|
||||
com.droiddeck.launcher.frontend.AutoComponents.queueGame(context, game, asked = true)
|
||||
val kept = com.droiddeck.launcher.frontend.AutoComponents.cachedFindings(context, appKey)
|
||||
if (kept != null) findingsKnown = true
|
||||
list to kept.orEmpty()
|
||||
} else {
|
||||
val own = gameDir?.let { DependencyDetector.detect(it) } ?: emptyList()
|
||||
val steam = steamAppId?.let { SteamRedists.detect(gameDir, it) } ?: emptyList()
|
||||
list to (own + steam).map { WinCompSources.Finding(it.componentName, it.reason, WinCompSources.Origin.FOLDER, false, listOf(it.reason, it.kind.name)) }
|
||||
}
|
||||
}
|
||||
offline = entries == null
|
||||
catalog = entries.orEmpty().associateBy { it.name }
|
||||
recommended = found
|
||||
findings = found
|
||||
selection = withContext(Dispatchers.IO) { WinComponents.selection(context, appKey) }
|
||||
present = withContext(Dispatchers.IO) { PrefixInstalledDetector.detect(compat) }
|
||||
installed = withContext(Dispatchers.IO) { WinComponents.installedIds(context).toSet() }
|
||||
}
|
||||
fun setPicks(next: List<String>) {
|
||||
picks = next
|
||||
coroutine.launch(Dispatchers.IO) { WinComponents.setPicks(context, appKey, next) }
|
||||
/** The user's own switch for [id]: kept apart from what was turned on by itself, and over it. */
|
||||
// Automatic picks still downloading show on their rows; when one lands the page reads the picks again.
|
||||
val autoDownloading by com.droiddeck.launcher.frontend.AutoComponents.downloading.collectAsState()
|
||||
// Without a network a component not downloaded yet cannot be turned on; the rows say so and
|
||||
// come back the moment the network does.
|
||||
val online = rememberOnline()
|
||||
val autoChanges by com.droiddeck.launcher.frontend.AutoComponents.changes.collectAsState()
|
||||
val pendingGames by com.droiddeck.launcher.frontend.AutoComponents.pendingGames.collectAsState()
|
||||
val pendingComponents by com.droiddeck.launcher.frontend.AutoComponents.pendingComponents.collectAsState()
|
||||
LaunchedEffect(autoChanges) {
|
||||
if (game != null) withContext(Dispatchers.IO) { com.droiddeck.launcher.frontend.AutoComponents.cachedFindings(context, appKey) }
|
||||
?.let { findings = it; if (appKey !in pendingGames) findingsKnown = true }
|
||||
selection = withContext(Dispatchers.IO) { WinComponents.selection(context, appKey) }
|
||||
installed = withContext(Dispatchers.IO) { WinComponents.installedIds(context).toSet() }
|
||||
}
|
||||
fun setUser(id: String, on: Boolean) {
|
||||
selection = WinComponents.Selection(selection.auto, selection.user + (id to on))
|
||||
coroutine.launch(Dispatchers.IO) { WinComponents.setUser(context, appKey, id, on) }
|
||||
}
|
||||
fun toggle(id: String, on: Boolean) {
|
||||
// Switches stay enabled during a download - a disabled one drops the pad's focus - so a press
|
||||
@@ -130,17 +195,16 @@ internal fun WinComponentsDialog(
|
||||
// Turning off is a saved pick, quick, but the row greys out until it is written so the
|
||||
// press is seen to land; the files leave the prefix at the game's next launch.
|
||||
busyId = id; reverting = true; progress = null
|
||||
val next = picks - id
|
||||
picks = next
|
||||
selection = WinComponents.Selection(selection.auto, selection.user + (id to false))
|
||||
coroutine.launch {
|
||||
withContext(Dispatchers.IO) { WinComponents.setPicks(context, appKey, next) }
|
||||
withContext(Dispatchers.IO) { WinComponents.setUser(context, appKey, id, false) }
|
||||
busyId = null; reverting = false
|
||||
}
|
||||
return
|
||||
}
|
||||
val all = catalog.orEmpty()
|
||||
val c = all[id]
|
||||
if (id in installed || c == null) { setPicks(picks + id); return }
|
||||
if (id in installed || c == null) { setUser(id, true); return }
|
||||
busyId = id; reverting = false
|
||||
progress = WinComponents.Progress(id, "", WinComponents.Phase.DOWNLOAD, -1)
|
||||
coroutine.launch {
|
||||
@@ -149,29 +213,48 @@ internal fun WinComponentsDialog(
|
||||
}
|
||||
progress = null
|
||||
installed = withContext(Dispatchers.IO) { WinComponents.installedIds(context).toSet() }
|
||||
if (problem != null) { error = problem; failed = id } else setPicks(picks + id)
|
||||
if (problem != null) { error = problem; failed = id } else setUser(id, true)
|
||||
busyId = null
|
||||
}
|
||||
}
|
||||
|
||||
val all = catalog
|
||||
// The first data is in: rows that show up from now on ease in.
|
||||
var arrived by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(all != null && findingsKnown) { if (all != null && findingsKnown) { withFrameNanos { }; arrived = true } }
|
||||
val here = if (all == null) emptySet() else present.map { installable(it, all) }.toSet()
|
||||
fun supportOf(id: String): Support =
|
||||
all?.get(id)?.let { WinComponents.support(it, all) } ?: if (id in installed) Support.READY else Support.UNSUPPORTED
|
||||
val recIds = if (all == null) emptyList() else recommended.distinctBy { installable(it.componentName, all) }
|
||||
// Turned on by itself for this game, each with its reason; then what is only recommended (the
|
||||
// folder scan, a name match, an auto pick that cannot install here); then what the lists name
|
||||
// that is no component here, by its own name.
|
||||
// Still being worked out on the background worker: the page says so instead of "nothing found".
|
||||
// Only before the game's list has ever been worked out: a list kept from before shows at once and
|
||||
// is checked again quietly, its changes easing in.
|
||||
val checking = game != null && !findingsKnown
|
||||
// Automatic picks on their way (queued or downloading) sit in their section already, with
|
||||
// their progress, and settle there when they land.
|
||||
val turningOn = if (all == null) emptyMap() else findings.filter { it.auto && it.component != null }
|
||||
.associateBy { installable(it.component!!, all) }
|
||||
.filterKeys { it !in selection.auto && selection.user[it] != false && (it in pendingComponents || it in autoDownloading) }
|
||||
val autoIds = selection.auto.keys.toList() + turningOn.keys
|
||||
val recIds = if (all == null) emptyList() else findings.filter { it.component != null }
|
||||
.filter { installable(it.component!!, all).let { id -> id !in selection.auto && id !in turningOn } }
|
||||
.distinctBy { installable(it.component!!, all) }
|
||||
val suggestions = findings.filter { it.component == null }.distinctBy { it.origin to it.original }
|
||||
val ready = all.orEmpty().values.filter { WinComponents.support(it, all.orEmpty()) == Support.READY }.map { it.name }
|
||||
val extra = (installed + picks).filter { all?.containsKey(it) != true }
|
||||
// What is turned on for this game gets its own section under the recommendations, which stay
|
||||
// where they are; the rest of the list is everything else, so a component turned off goes back
|
||||
// to its place in the alphabet. A component is listed once: the recommendations are left out
|
||||
// of the full list too.
|
||||
val recSet = recIds.map { installable(it.componentName, all.orEmpty()) }.toSet()
|
||||
val recSet = recIds.map { installable(it.component!!, all.orEmpty()) }.toSet() + autoIds
|
||||
val active = picks.filter { it !in recSet && (all?.containsKey(it) == true || it in installed) }.distinct()
|
||||
.sortedBy { WinComponentNames.of(it).lowercase() }
|
||||
val list = (ready + extra).distinct().filter { it !in active && it !in recSet }.sortedBy { WinComponentNames.of(it).lowercase() }
|
||||
val waiting = all.orEmpty().values.filter { WinComponents.support(it, all.orEmpty()) == Support.NEEDS_INSTALLER }
|
||||
.map { it.name }.sortedBy { WinComponentNames.of(it).lowercase() }
|
||||
val hasRec = all != null && recIds.any { supportOf(installable(it.componentName, all)) == Support.READY }
|
||||
val hasRec = all != null && (autoIds.isNotEmpty() || recIds.any { supportOf(installable(it.component!!, all)) == Support.READY })
|
||||
val sections = listOfNotNull(recFocus.takeIf { hasRec }, activeFocus.takeIf { active.isNotEmpty() },
|
||||
allFocus.takeIf { list.isNotEmpty() }, waitFocus.takeIf { waiting.isNotEmpty() }, doneFocus)
|
||||
var section by remember { mutableStateOf(0) }
|
||||
@@ -198,13 +281,14 @@ internal fun WinComponentsDialog(
|
||||
DialogHeader(gameName, stringResource(R.string.wincomp_title))
|
||||
Small(stringResource(R.string.wincomp_applies))
|
||||
if (byPad) Small(stringResource(R.string.wincomp_pad_hint))
|
||||
if (all == null) Small(stringResource(R.string.wincomp_loading))
|
||||
if (all == null && slowCatalog) Small(stringResource(R.string.wincomp_loading))
|
||||
if (offline) Small(stringResource(R.string.wincomp_offline), error = true)
|
||||
@Composable
|
||||
fun Item(id: String, reason: String?, focus: FocusRequester?) {
|
||||
val c = all?.get(id)
|
||||
val support = supportOf(id)
|
||||
val status = when {
|
||||
!online && id !in installed && support == Support.READY -> stringResource(R.string.wincomp_when_online)
|
||||
id == failed && error != null -> stringResource(R.string.wincomp_failed, error.orEmpty())
|
||||
id in here && id !in picks -> stringResource(R.string.wincomp_already_here)
|
||||
id in installed -> stringResource(R.string.wincomp_downloaded)
|
||||
@@ -214,12 +298,15 @@ internal fun WinComponentsDialog(
|
||||
}
|
||||
// The catalog key stays in the detail: it is what a log or a bug report names.
|
||||
val detail = listOfNotNull(reason, c?.description?.takeIf { it.isNotEmpty() }, status, id).joinToString(" · ")
|
||||
val usable = support == Support.READY
|
||||
val busy = id == busyId
|
||||
val offlineHere = !online && id !in installed
|
||||
val usable = support == Support.READY && !offlineHere
|
||||
val autoProgress = autoDownloading[id]?.takeIf { !offlineHere }
|
||||
val busy = id == busyId || autoProgress != null
|
||||
val shownProgress = if (id == busyId) progress else autoProgress
|
||||
val caption = when {
|
||||
!busy -> null
|
||||
reverting -> stringResource(R.string.wincomp_reverting)
|
||||
else -> progress?.let { p ->
|
||||
id == busyId && reverting -> stringResource(R.string.wincomp_reverting)
|
||||
else -> shownProgress?.let { p ->
|
||||
val stage = if (p.component != id && p.stage.isNotEmpty()) "${WinComponentNames.of(p.component)}: ${p.stage}" else p.stage
|
||||
val text = when (p.phase) {
|
||||
WinComponents.Phase.DOWNLOAD -> stringResource(R.string.wincomp_downloading, stage)
|
||||
@@ -229,9 +316,9 @@ internal fun WinComponentsDialog(
|
||||
}
|
||||
}
|
||||
ComponentRow(
|
||||
WinComponentNames.of(id), detail, checked = id in picks, enabled = usable || id in picks,
|
||||
WinComponentNames.of(id), detail, checked = id in picks, enabled = usable || (id in picks && id in installed),
|
||||
dim = !usable || busy, busy = busy, caption = caption,
|
||||
percent = if (busy && !reverting) progress?.percent ?: -1 else -1,
|
||||
percent = if (busy && !reverting) shownProgress?.percent ?: -1 else -1,
|
||||
modifier = (if (usable && focus != null) Modifier.focusRequester(focus) else Modifier)
|
||||
.onFocusChanged { if (it.isFocused) switchFocused = true },
|
||||
) { on -> toggle(id, on) }
|
||||
@@ -242,48 +329,97 @@ internal fun WinComponentsDialog(
|
||||
return { id -> focus.takeIf { id == first } }
|
||||
}
|
||||
|
||||
if (all != null && recIds.isNotEmpty()) {
|
||||
Section(stringResource(R.string.wincomp_recommended))
|
||||
val focusOf = firstUsable(recIds.map { installable(it.componentName, all) }, recFocus)
|
||||
Panel {
|
||||
recIds.forEachIndexed { i, rec ->
|
||||
if (i > 0) Divider()
|
||||
val reason = stringResource(
|
||||
when (rec.kind) {
|
||||
DependencyDetector.Kind.BUNDLED -> R.string.wincomp_found_bundled
|
||||
DependencyDetector.Kind.SHIPPED -> R.string.wincomp_found_shipped
|
||||
DependencyDetector.Kind.STEAM -> R.string.wincomp_found_steam
|
||||
}, rec.reason,
|
||||
)
|
||||
val id = installable(rec.componentName, all)
|
||||
Item(id, reason, focusOf(id))
|
||||
// The lists only once the catalog is in, faded in once: no half list counted before it.
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = all != null,
|
||||
enter = androidx.compose.animation.fadeIn(Motion.tw(220)),
|
||||
exit = androidx.compose.animation.fadeOut(Motion.tw(0)),
|
||||
label = "lists",
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp), modifier = Modifier.fillMaxWidth()) {
|
||||
if (checking) Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
androidx.compose.material3.CircularProgressIndicator(Modifier.padding(start = 2.dp).size(12.dp), strokeWidth = 1.5.dp)
|
||||
Small(stringResource(R.string.wincomp_checking))
|
||||
}
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = all != null && autoIds.isNotEmpty(),
|
||||
enter = androidx.compose.animation.fadeIn(Motion.tw(260)) + androidx.compose.animation.expandVertically(Motion.tw(260)),
|
||||
exit = androidx.compose.animation.fadeOut(Motion.tw(180)) + androidx.compose.animation.shrinkVertically(Motion.tw(200)),
|
||||
label = "autoSection",
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Section(stringResource(R.string.wincomp_auto_section))
|
||||
if (turningOn.isNotEmpty()) Small(pluralStringResource(R.plurals.wincomp_turning_on, turningOn.size, turningOn.size))
|
||||
val focusOf = firstUsable(autoIds, recFocus)
|
||||
Panel {
|
||||
autoIds.forEachIndexed { i, id ->
|
||||
key(id) {
|
||||
Arrive(arrived) {
|
||||
Column {
|
||||
if (i > 0) Divider()
|
||||
val reason = selection.auto[id] ?: turningOn[id]?.let { f -> WinComponents.AutoReason(f.origin.name, listOf(f.original) + f.args) }
|
||||
Item(id, reason?.let { autoReason(it) }, focusOf(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (all != null && recIds.isNotEmpty()) {
|
||||
Section(stringResource(R.string.wincomp_recommended))
|
||||
val focusOf = firstUsable(recIds.map { installable(it.component!!, all) }, if (autoIds.isEmpty()) recFocus else recFocusAfterAuto)
|
||||
Panel {
|
||||
recIds.forEachIndexed { i, rec ->
|
||||
val id = installable(rec.component!!, all)
|
||||
key(id) {
|
||||
Arrive(arrived) {
|
||||
Column {
|
||||
if (i > 0) Divider()
|
||||
Item(id, findingReason(rec), focusOf(id))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (all != null && gameDir != null && autoIds.isEmpty() && !checking) Small(stringResource(R.string.wincomp_no_recommendation))
|
||||
if (suggestions.isNotEmpty()) {
|
||||
Section(stringResource(R.string.wincomp_suggestions))
|
||||
Panel {
|
||||
suggestions.forEachIndexed { i, f ->
|
||||
if (i > 0) Divider()
|
||||
Column(Modifier.fillMaxWidth().padding(horizontal = 14.dp, vertical = 10.dp)) {
|
||||
Text(f.original, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onBackground)
|
||||
Text(stringResource(R.string.wincomp_suggestion_reason, originName(f.origin.name)), fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (active.isNotEmpty()) {
|
||||
Section(stringResource(R.string.wincomp_active, active.size))
|
||||
val focusOf = firstUsable(active, activeFocus)
|
||||
Panel { active.forEachIndexed { i, id -> if (i > 0) Divider(); Item(id, null, focusOf(id)) } }
|
||||
}
|
||||
if (list.isNotEmpty()) {
|
||||
Section(stringResource(R.string.wincomp_all, list.size))
|
||||
val focusOf = firstUsable(list, allFocus)
|
||||
Panel { list.forEachIndexed { i, id -> if (i > 0) Divider(); Item(id, null, focusOf(id)) } }
|
||||
}
|
||||
if (waiting.isNotEmpty()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Section(stringResource(R.string.wincomp_installers, waiting.size))
|
||||
Spacer(Modifier.weight(1f))
|
||||
SecondaryButton(
|
||||
stringResource(if (showWaiting) R.string.wincomp_hide else R.string.wincomp_show), compact = true,
|
||||
modifier = Modifier.focusRequester(waitFocus),
|
||||
) { showWaiting = !showWaiting }
|
||||
}
|
||||
Small(stringResource(R.string.wincomp_installers_note))
|
||||
if (showWaiting) Panel { waiting.forEachIndexed { i, id -> if (i > 0) Divider(); Item(id, null, null) } }
|
||||
}
|
||||
}
|
||||
} else if (all != null && gameDir != null) Small(stringResource(R.string.wincomp_no_recommendation))
|
||||
|
||||
if (active.isNotEmpty()) {
|
||||
Section(stringResource(R.string.wincomp_active, active.size))
|
||||
val focusOf = firstUsable(active, activeFocus)
|
||||
Panel { active.forEachIndexed { i, id -> if (i > 0) Divider(); Item(id, null, focusOf(id)) } }
|
||||
}
|
||||
if (list.isNotEmpty()) {
|
||||
Section(stringResource(R.string.wincomp_all, list.size))
|
||||
val focusOf = firstUsable(list, allFocus)
|
||||
Panel { list.forEachIndexed { i, id -> if (i > 0) Divider(); Item(id, null, focusOf(id)) } }
|
||||
}
|
||||
if (waiting.isNotEmpty()) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Section(stringResource(R.string.wincomp_installers, waiting.size))
|
||||
Spacer(Modifier.weight(1f))
|
||||
SecondaryButton(
|
||||
stringResource(if (showWaiting) R.string.wincomp_hide else R.string.wincomp_show), compact = true,
|
||||
modifier = Modifier.focusRequester(waitFocus),
|
||||
) { showWaiting = !showWaiting }
|
||||
}
|
||||
Small(stringResource(R.string.wincomp_installers_note))
|
||||
if (showWaiting) Panel { waiting.forEachIndexed { i, id -> if (i > 0) Divider(); Item(id, null, null) } }
|
||||
}
|
||||
|
||||
error?.let { Small(it, error = true) }
|
||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 4.dp)) {
|
||||
Small(stringResource(R.string.wincomp_next_launch))
|
||||
@@ -293,6 +429,39 @@ internal fun WinComponentsDialog(
|
||||
}
|
||||
}
|
||||
|
||||
/** Once the page has its first data ([arrived]), a row that shows up eases in; the first ones are simply there. */
|
||||
@Composable
|
||||
private fun Arrive(arrived: Boolean, content: @Composable () -> Unit) {
|
||||
val state = remember { androidx.compose.animation.core.MutableTransitionState(!arrived) }.apply { targetState = true }
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visibleState = state,
|
||||
enter = androidx.compose.animation.fadeIn(Motion.tw(240)) + androidx.compose.animation.expandVertically(Motion.tw(240)) +
|
||||
androidx.compose.animation.slideInVertically(Motion.tw(240)) { -it / 4 },
|
||||
exit = androidx.compose.animation.fadeOut(Motion.tw(160)),
|
||||
label = "arrive",
|
||||
) { content() }
|
||||
}
|
||||
|
||||
/** Whether the default network is there and validated, followed live. */
|
||||
@Composable
|
||||
private fun rememberOnline(): Boolean {
|
||||
val context = LocalContext.current
|
||||
var online by remember { mutableStateOf(com.droiddeck.launcher.frontend.AutoComponents.isOnline(context)) }
|
||||
androidx.compose.runtime.DisposableEffect(Unit) {
|
||||
val cm = context.getSystemService(android.net.ConnectivityManager::class.java)
|
||||
val main = android.os.Handler(android.os.Looper.getMainLooper())
|
||||
val callback = object : android.net.ConnectivityManager.NetworkCallback() {
|
||||
private fun update() = main.post { online = com.droiddeck.launcher.frontend.AutoComponents.isOnline(context) }
|
||||
override fun onAvailable(network: android.net.Network) { update() }
|
||||
override fun onLost(network: android.net.Network) { update() }
|
||||
override fun onCapabilitiesChanged(network: android.net.Network, caps: android.net.NetworkCapabilities) { update() }
|
||||
}
|
||||
runCatching { cm?.registerDefaultNetworkCallback(callback) }
|
||||
onDispose { runCatching { cm?.unregisterNetworkCallback(callback) } }
|
||||
}
|
||||
return online
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Section(title: String) =
|
||||
Text(title.uppercase(), fontSize = 12.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 1.5.sp, color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
|
||||
@@ -312,12 +312,30 @@
|
||||
<string name="wincomp_title">Componentes de Windows</string>
|
||||
<string name="wincomp_pad_hint">LB / RB saltan entre las secciones.</string>
|
||||
<string name="wincomp_card_none">Ninguno</string>
|
||||
<string name="wincomp_applies">DLL de Windows que Proton no incluye, de la lista de componentes de Bannerlator. Activa las que necesita este juego: van a la carpeta de Windows propia del juego, nunca a sus archivos, y se mantienen con cualquier Proton que use.</string>
|
||||
<string name="wincomp_applies">DLL de Windows que Proton no incluye, de la lista de componentes. Activa las que necesita este juego: van a la carpeta de Windows propia del juego, nunca a sus archivos, y se mantienen con cualquier Proton que use.</string>
|
||||
<string name="wincomp_offline">No se pudo acceder a la lista de componentes. Los componentes ya descargados se pueden seguir activando o desactivando.</string>
|
||||
<string name="wincomp_recommended">Recomendados para este juego</string>
|
||||
<string name="wincomp_checking">Comprobando lo que necesita este juego…</string>
|
||||
<string name="wincomp_when_online">Disponible con conexión</string>
|
||||
<plurals name="wincomp_turning_on">
|
||||
<item quantity="one">Activando %1$d componente…</item>
|
||||
<item quantity="many">Activando %1$d componentes…</item>
|
||||
<item quantity="other">Activando %1$d componentes…</item>
|
||||
</plurals>
|
||||
<string name="wincomp_auto_section">Activados para este juego</string>
|
||||
<string name="wincomp_auto_steam">Steam instala %1$s con %2$s (app %3$s)</string>
|
||||
<string name="wincomp_auto_gog">De la lista de GOG para este juego</string>
|
||||
<string name="wincomp_auto_epic">De los requisitos previos de Epic para este juego</string>
|
||||
<string name="wincomp_auto_amazon">De los pasos de instalación de Amazon para este juego</string>
|
||||
<string name="wincomp_steam_by_name">Steam instala %1$s con la app %2$s, encontrada por nombre</string>
|
||||
<string name="wincomp_suggestions">También en la lista</string>
|
||||
<string name="wincomp_suggestion_reason">En la lista de %1$s; no es un componente aquí</string>
|
||||
<string name="wincomp_src_steam">Steam</string>
|
||||
<string name="wincomp_src_gog">GOG</string>
|
||||
<string name="wincomp_src_epic">Epic Games</string>
|
||||
<string name="wincomp_src_amazon">Amazon Games</string>
|
||||
<string name="wincomp_found_bundled">El juego incluye %1$s</string>
|
||||
<string name="wincomp_found_shipped">El juego tiene %1$s (suele funcionar tal cual)</string>
|
||||
<string name="wincomp_found_steam">Steam instala %1$s con este juego</string>
|
||||
<string name="wincomp_already_here">Ya está en este juego</string>
|
||||
<string name="wincomp_no_recommendation">Nada en la carpeta del juego apunta a un componente.</string>
|
||||
<string name="wincomp_all">Todos los componentes (%1$d)</string>
|
||||
|
||||
@@ -312,12 +312,28 @@
|
||||
<string name="wincomp_title">Windowsコンポーネント</string>
|
||||
<string name="wincomp_pad_hint">LB / RBでセクション間を移動します。</string>
|
||||
<string name="wincomp_card_none">なし</string>
|
||||
<string name="wincomp_applies">Protonに含まれないWindows DLLです(Bannerlatorのコンポーネント一覧から)。このゲームに必要なものをオンにしてください。ゲーム専用のWindowsフォルダーに入り、ゲームのファイルには触れず、どのProtonで実行しても維持されます。</string>
|
||||
<string name="wincomp_applies">Protonに含まれないWindows DLLです(コンポーネント一覧から)。このゲームに必要なものをオンにしてください。ゲーム専用のWindowsフォルダーに入り、ゲームのファイルには触れず、どのProtonで実行しても維持されます。</string>
|
||||
<string name="wincomp_offline">コンポーネント一覧に接続できませんでした。ダウンロード済みのコンポーネントは引き続き切り替えられます。</string>
|
||||
<string name="wincomp_recommended">このゲームにおすすめ</string>
|
||||
<string name="wincomp_checking">このゲームに必要なものを確認しています…</string>
|
||||
<string name="wincomp_when_online">オンライン時に利用可能</string>
|
||||
<plurals name="wincomp_turning_on">
|
||||
<item quantity="other">%1$d 個のコンポーネントを有効にしています…</item>
|
||||
</plurals>
|
||||
<string name="wincomp_auto_section">このゲームで有効</string>
|
||||
<string name="wincomp_auto_steam">Steamが%2$s(アプリ %3$s)と一緒に%1$sをインストールします</string>
|
||||
<string name="wincomp_auto_gog">このゲームのGOGのリストから</string>
|
||||
<string name="wincomp_auto_epic">このゲームのEpicの前提条件から</string>
|
||||
<string name="wincomp_auto_amazon">このゲームのAmazonのインストール手順から</string>
|
||||
<string name="wincomp_steam_by_name">Steamがアプリ %2$sと一緒に%1$sをインストールします(名前で一致)</string>
|
||||
<string name="wincomp_suggestions">その他のリスト</string>
|
||||
<string name="wincomp_suggestion_reason">%1$sのリストにあります。ここではコンポーネントではありません</string>
|
||||
<string name="wincomp_src_steam">Steam</string>
|
||||
<string name="wincomp_src_gog">GOG</string>
|
||||
<string name="wincomp_src_epic">Epic Games</string>
|
||||
<string name="wincomp_src_amazon">Amazon Games</string>
|
||||
<string name="wincomp_found_bundled">ゲームに%1$sが同梱されています</string>
|
||||
<string name="wincomp_found_shipped">ゲームに%1$sがあります(そのままで動くことが多いです)</string>
|
||||
<string name="wincomp_found_steam">Steamがこのゲームと一緒に%1$sをインストールします</string>
|
||||
<string name="wincomp_already_here">このゲームに導入済み</string>
|
||||
<string name="wincomp_no_recommendation">ゲームのフォルダーには、必要なコンポーネントを示すものがありません。</string>
|
||||
<string name="wincomp_all">すべてのコンポーネント(%1$d)</string>
|
||||
|
||||
@@ -312,12 +312,28 @@
|
||||
<string name="wincomp_title">Windows 구성 요소</string>
|
||||
<string name="wincomp_pad_hint">LB / RB로 섹션 사이를 이동합니다.</string>
|
||||
<string name="wincomp_card_none">없음</string>
|
||||
<string name="wincomp_applies">Proton에 포함되지 않은 Windows DLL로, Bannerlator의 구성 요소 목록에서 가져옵니다. 이 게임에 필요한 것을 켜세요. 게임 전용 Windows 폴더에 들어가며 게임 파일은 건드리지 않고, 어떤 Proton으로 실행해도 유지됩니다.</string>
|
||||
<string name="wincomp_applies">Proton에 포함되지 않은 Windows DLL로, 구성 요소 목록에서 가져옵니다. 이 게임에 필요한 것을 켜세요. 게임 전용 Windows 폴더에 들어가며 게임 파일은 건드리지 않고, 어떤 Proton으로 실행해도 유지됩니다.</string>
|
||||
<string name="wincomp_offline">구성 요소 목록에 연결할 수 없습니다. 이미 다운로드한 구성 요소는 계속 켜고 끌 수 있습니다.</string>
|
||||
<string name="wincomp_recommended">이 게임에 권장</string>
|
||||
<string name="wincomp_checking">이 게임에 필요한 것을 확인하는 중…</string>
|
||||
<string name="wincomp_when_online">온라인일 때 사용 가능</string>
|
||||
<plurals name="wincomp_turning_on">
|
||||
<item quantity="other">구성 요소 %1$d개를 켜는 중…</item>
|
||||
</plurals>
|
||||
<string name="wincomp_auto_section">이 게임에 켜짐</string>
|
||||
<string name="wincomp_auto_steam">Steam이 %2$s(앱 %3$s)과(와) 함께 %1$s을(를) 설치합니다</string>
|
||||
<string name="wincomp_auto_gog">이 게임의 GOG 목록에서</string>
|
||||
<string name="wincomp_auto_epic">이 게임의 Epic 사전 요구 사항에서</string>
|
||||
<string name="wincomp_auto_amazon">이 게임의 Amazon 설치 단계에서</string>
|
||||
<string name="wincomp_steam_by_name">Steam이 앱 %2$s과(와) 함께 %1$s을(를) 설치합니다(이름으로 일치)</string>
|
||||
<string name="wincomp_suggestions">함께 나열됨</string>
|
||||
<string name="wincomp_suggestion_reason">%1$s 목록에 있음; 여기서는 구성 요소가 아님</string>
|
||||
<string name="wincomp_src_steam">Steam</string>
|
||||
<string name="wincomp_src_gog">GOG</string>
|
||||
<string name="wincomp_src_epic">Epic Games</string>
|
||||
<string name="wincomp_src_amazon">Amazon Games</string>
|
||||
<string name="wincomp_found_bundled">게임에 %1$s이(가) 포함되어 있습니다</string>
|
||||
<string name="wincomp_found_shipped">게임에 %1$s이(가) 있습니다 (대개 그대로 작동)</string>
|
||||
<string name="wincomp_found_steam">Steam이 이 게임과 함께 %1$s을(를) 설치합니다</string>
|
||||
<string name="wincomp_already_here">이 게임에 이미 있음</string>
|
||||
<string name="wincomp_no_recommendation">게임 폴더에서 필요한 구성 요소를 가리키는 것이 없습니다.</string>
|
||||
<string name="wincomp_all">모든 구성 요소 (%1$d)</string>
|
||||
|
||||
@@ -312,12 +312,28 @@
|
||||
<string name="wincomp_title">Windows 组件</string>
|
||||
<string name="wincomp_pad_hint">按 LB / RB 在各部分之间跳转。</string>
|
||||
<string name="wincomp_card_none">无</string>
|
||||
<string name="wincomp_applies">Proton 未包含的 Windows DLL,来自 Bannerlator 的组件列表。开启此游戏需要的组件:它们会放入游戏自己的 Windows 文件夹,绝不改动游戏文件,并且无论游戏使用哪个 Proton 都会保留。</string>
|
||||
<string name="wincomp_applies">Proton 未包含的 Windows DLL,来自组件列表。开启此游戏需要的组件:它们会放入游戏自己的 Windows 文件夹,绝不改动游戏文件,并且无论游戏使用哪个 Proton 都会保留。</string>
|
||||
<string name="wincomp_offline">无法访问组件列表。已下载的组件仍可开启或关闭。</string>
|
||||
<string name="wincomp_recommended">此游戏推荐</string>
|
||||
<string name="wincomp_checking">正在检查此游戏需要什么…</string>
|
||||
<string name="wincomp_when_online">联网后可用</string>
|
||||
<plurals name="wincomp_turning_on">
|
||||
<item quantity="other">正在开启 %1$d 个组件…</item>
|
||||
</plurals>
|
||||
<string name="wincomp_auto_section">此游戏已开启</string>
|
||||
<string name="wincomp_auto_steam">Steam 会随 %2$s(应用 %3$s)安装 %1$s</string>
|
||||
<string name="wincomp_auto_gog">来自此游戏的 GOG 列表</string>
|
||||
<string name="wincomp_auto_epic">来自此游戏的 Epic 先决条件</string>
|
||||
<string name="wincomp_auto_amazon">来自此游戏的 Amazon 安装步骤</string>
|
||||
<string name="wincomp_steam_by_name">Steam 会随应用 %2$s 安装 %1$s(按名称匹配)</string>
|
||||
<string name="wincomp_suggestions">列表中的其他项</string>
|
||||
<string name="wincomp_suggestion_reason">%1$s 列出;此处不是组件</string>
|
||||
<string name="wincomp_src_steam">Steam</string>
|
||||
<string name="wincomp_src_gog">GOG</string>
|
||||
<string name="wincomp_src_epic">Epic Games</string>
|
||||
<string name="wincomp_src_amazon">Amazon Games</string>
|
||||
<string name="wincomp_found_bundled">游戏自带 %1$s</string>
|
||||
<string name="wincomp_found_shipped">游戏含有 %1$s(通常可直接使用)</string>
|
||||
<string name="wincomp_found_steam">Steam 会随此游戏安装 %1$s</string>
|
||||
<string name="wincomp_already_here">此游戏中已有</string>
|
||||
<string name="wincomp_no_recommendation">游戏文件夹中没有指向任何组件的内容。</string>
|
||||
<string name="wincomp_all">全部组件(%1$d)</string>
|
||||
|
||||
@@ -312,12 +312,28 @@
|
||||
<string name="wincomp_title">Windows 組件</string>
|
||||
<string name="wincomp_pad_hint">按 LB / RB 在各部分之間切換。</string>
|
||||
<string name="wincomp_card_none">無</string>
|
||||
<string name="wincomp_applies">Proton 未有包含的 Windows DLL,取自 Bannerlator 的組件清單。開啟這款遊戲需要的組件:它們會放進遊戲自己的 Windows 資料夾,絕不改動遊戲檔案,而且無論遊戲用哪個 Proton 都會保留。</string>
|
||||
<string name="wincomp_applies">Proton 未有包含的 Windows DLL,取自組件清單。開啟這款遊戲需要的組件:它們會放進遊戲自己的 Windows 資料夾,絕不改動遊戲檔案,而且無論遊戲用哪個 Proton 都會保留。</string>
|
||||
<string name="wincomp_offline">無法連接組件清單。已下載的組件仍可開啟或關閉。</string>
|
||||
<string name="wincomp_recommended">建議用於這款遊戲</string>
|
||||
<string name="wincomp_checking">正在檢查這款遊戲需要什麼…</string>
|
||||
<string name="wincomp_when_online">連線後可用</string>
|
||||
<plurals name="wincomp_turning_on">
|
||||
<item quantity="other">正在開啟 %1$d 個元件…</item>
|
||||
</plurals>
|
||||
<string name="wincomp_auto_section">這款遊戲已開啟</string>
|
||||
<string name="wincomp_auto_steam">Steam 會隨 %2$s(應用程式 %3$s)安裝 %1$s</string>
|
||||
<string name="wincomp_auto_gog">來自這款遊戲的 GOG 清單</string>
|
||||
<string name="wincomp_auto_epic">來自這款遊戲的 Epic 必要元件</string>
|
||||
<string name="wincomp_auto_amazon">來自這款遊戲的 Amazon 安裝步驟</string>
|
||||
<string name="wincomp_steam_by_name">Steam 會隨應用程式 %2$s 安裝 %1$s(依名稱比對)</string>
|
||||
<string name="wincomp_suggestions">清單中的其他項目</string>
|
||||
<string name="wincomp_suggestion_reason">%1$s 列出;這裡不是元件</string>
|
||||
<string name="wincomp_src_steam">Steam</string>
|
||||
<string name="wincomp_src_gog">GOG</string>
|
||||
<string name="wincomp_src_epic">Epic Games</string>
|
||||
<string name="wincomp_src_amazon">Amazon Games</string>
|
||||
<string name="wincomp_found_bundled">遊戲附帶 %1$s</string>
|
||||
<string name="wincomp_found_shipped">遊戲含有 %1$s(通常可直接使用)</string>
|
||||
<string name="wincomp_found_steam">Steam 會隨這款遊戲安裝 %1$s</string>
|
||||
<string name="wincomp_already_here">這款遊戲中已有</string>
|
||||
<string name="wincomp_no_recommendation">遊戲資料夾內沒有指向任何組件的內容。</string>
|
||||
<string name="wincomp_all">所有組件(%1$d)</string>
|
||||
|
||||
@@ -312,12 +312,28 @@
|
||||
<string name="wincomp_title">Windows 元件</string>
|
||||
<string name="wincomp_pad_hint">按 LB / RB 在各區段之間切換。</string>
|
||||
<string name="wincomp_card_none">無</string>
|
||||
<string name="wincomp_applies">Proton 未內含的 Windows DLL,取自 Bannerlator 的元件清單。開啟這款遊戲需要的元件:它們會放進遊戲自己的 Windows 資料夾,絕不更動遊戲檔案,且不論遊戲使用哪個 Proton 都會保留。</string>
|
||||
<string name="wincomp_applies">Proton 未內含的 Windows DLL,取自元件清單。開啟這款遊戲需要的元件:它們會放進遊戲自己的 Windows 資料夾,絕不更動遊戲檔案,且不論遊戲使用哪個 Proton 都會保留。</string>
|
||||
<string name="wincomp_offline">無法連上元件清單。已下載的元件仍可開啟或關閉。</string>
|
||||
<string name="wincomp_recommended">建議用於這款遊戲</string>
|
||||
<string name="wincomp_checking">正在檢查這款遊戲需要什麼…</string>
|
||||
<string name="wincomp_when_online">連線後可用</string>
|
||||
<plurals name="wincomp_turning_on">
|
||||
<item quantity="other">正在開啟 %1$d 個元件…</item>
|
||||
</plurals>
|
||||
<string name="wincomp_auto_section">這款遊戲已開啟</string>
|
||||
<string name="wincomp_auto_steam">Steam 會隨 %2$s(應用程式 %3$s)安裝 %1$s</string>
|
||||
<string name="wincomp_auto_gog">來自這款遊戲的 GOG 清單</string>
|
||||
<string name="wincomp_auto_epic">來自這款遊戲的 Epic 必要元件</string>
|
||||
<string name="wincomp_auto_amazon">來自這款遊戲的 Amazon 安裝步驟</string>
|
||||
<string name="wincomp_steam_by_name">Steam 會隨應用程式 %2$s 安裝 %1$s(依名稱比對)</string>
|
||||
<string name="wincomp_suggestions">清單中的其他項目</string>
|
||||
<string name="wincomp_suggestion_reason">%1$s 列出;這裡不是元件</string>
|
||||
<string name="wincomp_src_steam">Steam</string>
|
||||
<string name="wincomp_src_gog">GOG</string>
|
||||
<string name="wincomp_src_epic">Epic Games</string>
|
||||
<string name="wincomp_src_amazon">Amazon Games</string>
|
||||
<string name="wincomp_found_bundled">遊戲隨附 %1$s</string>
|
||||
<string name="wincomp_found_shipped">遊戲含有 %1$s(通常可直接使用)</string>
|
||||
<string name="wincomp_found_steam">Steam 會隨這款遊戲安裝 %1$s</string>
|
||||
<string name="wincomp_already_here">這款遊戲中已有</string>
|
||||
<string name="wincomp_no_recommendation">遊戲資料夾中沒有指向任何元件的內容。</string>
|
||||
<string name="wincomp_all">所有元件(%1$d)</string>
|
||||
|
||||
@@ -322,12 +322,29 @@
|
||||
<string name="wincomp_title">Windows components</string>
|
||||
<string name="wincomp_pad_hint">LB / RB jump between the sections.</string>
|
||||
<string name="wincomp_card_none">None</string>
|
||||
<string name="wincomp_applies">Windows DLLs Proton does not include, from Bannerlator\'s component list. Turn on the ones this game needs: they go into the game\'s own Windows folder, never its files, and stay whichever Proton it runs on.</string>
|
||||
<string name="wincomp_applies">Windows DLLs Proton does not include, from the component list. Turn on the ones this game needs: they go into the game\'s own Windows folder, never its files, and stay whichever Proton it runs on.</string>
|
||||
<string name="wincomp_offline">Could not reach the component list. Components already downloaded can still be switched.</string>
|
||||
<string name="wincomp_recommended">Recommended for this game</string>
|
||||
<string name="wincomp_checking">Checking what this game needs…</string>
|
||||
<string name="wincomp_when_online">Available when online</string>
|
||||
<plurals name="wincomp_turning_on">
|
||||
<item quantity="one">Turning on %1$d component…</item>
|
||||
<item quantity="other">Turning on %1$d components…</item>
|
||||
</plurals>
|
||||
<string name="wincomp_auto_section">On for this game</string>
|
||||
<string name="wincomp_auto_steam">Steam installs %1$s with %2$s (app %3$s)</string>
|
||||
<string name="wincomp_auto_gog">From GOG\'s list for this game</string>
|
||||
<string name="wincomp_auto_epic">From Epic\'s prerequisites for this game</string>
|
||||
<string name="wincomp_auto_amazon">From Amazon\'s install steps for this game</string>
|
||||
<string name="wincomp_steam_by_name">Steam installs %1$s with app %2$s, matched by name</string>
|
||||
<string name="wincomp_suggestions">Also listed</string>
|
||||
<string name="wincomp_suggestion_reason">Listed by %1$s; not a component here</string>
|
||||
<string name="wincomp_src_steam">Steam</string>
|
||||
<string name="wincomp_src_gog">GOG</string>
|
||||
<string name="wincomp_src_epic">Epic Games</string>
|
||||
<string name="wincomp_src_amazon">Amazon Games</string>
|
||||
<string name="wincomp_found_bundled">The game ships %1$s</string>
|
||||
<string name="wincomp_found_shipped">The game has %1$s (often works as is)</string>
|
||||
<string name="wincomp_found_steam">Steam installs %1$s with this game</string>
|
||||
<string name="wincomp_already_here">Already in this game</string>
|
||||
<string name="wincomp_no_recommendation">Nothing in the game\'s folder points at a component.</string>
|
||||
<string name="wincomp_active">Installed for this game (%1$d)</string>
|
||||
|
||||
@@ -33,27 +33,40 @@ class AddedGameArtLookupTest {
|
||||
}
|
||||
|
||||
@Test fun anAppidFromTheGamesFilesIsUsedWithoutASearch() {
|
||||
val lookup = File(tmp.newFolder(), "steam-appid")
|
||||
val id = AddedGameArt.steamAppId("Palworld", 1623730, lookup) { fail("searched"); null }
|
||||
assertEquals("1623730", id)
|
||||
assertEquals("1623730", lookup.readText())
|
||||
// An earlier "none" from a search does not stand in its way.
|
||||
lookup.writeText("none")
|
||||
assertEquals("1623730", AddedGameArt.steamAppId("Palworld", 1623730, lookup) { fail("searched"); null })
|
||||
val dir = tmp.newFolder("Palworld")
|
||||
File(dir, "steam_appid.txt").writeText("1623730")
|
||||
val exe = File(dir, "Palworld.exe").apply { writeText("x") }
|
||||
val match = SteamMatch.resolve(app, dir.path, exe, "Palworld") { fail("searched"); null }
|
||||
assertEquals(1623730, match.appId)
|
||||
assertEquals(SteamMatch.Certainty.FILES, match.certainty)
|
||||
// The same record for everyone who asks.
|
||||
assertEquals(1623730, SteamMatch.get(app, dir.path)!!.appId)
|
||||
}
|
||||
|
||||
@Test fun withoutAnAppidTheTitleIsSearchedOnceAWeek() {
|
||||
val lookup = File(tmp.newFolder(), "steam-appid")
|
||||
val dir = tmp.newFolder("Nothing Like It")
|
||||
val exe = File(dir, "game.exe").apply { writeText("x") }
|
||||
var searches = 0
|
||||
assertNull(AddedGameArt.steamAppId("Nothing Like It", null, lookup) { searches++; null })
|
||||
assertEquals("none", lookup.readText())
|
||||
assertNull(AddedGameArt.steamAppId("Nothing Like It", null, lookup) { searches++; "1" })
|
||||
assertEquals(null, SteamMatch.resolve(app, dir.path, exe, "Nothing Like It") { searches++; null }.appId)
|
||||
assertEquals(null, SteamMatch.resolve(app, dir.path, exe, "Nothing Like It") { searches++; 1 }.appId)
|
||||
assertEquals(1, searches)
|
||||
lookup.setLastModified(System.currentTimeMillis() - 8L * 24 * 3600 * 1000)
|
||||
assertEquals("42", AddedGameArt.steamAppId("Nothing Like It", null, lookup) { searches++; "42" })
|
||||
val old = SteamMatch.get(app, dir.path)!!
|
||||
SteamMatch.put(app, dir.path, SteamMatch.Match(null, SteamMatch.Certainty.NONE, "", old.checkedAt - 8L * 24 * 3600 * 1000))
|
||||
val found = SteamMatch.resolve(app, dir.path, exe, "Nothing Like It") { searches++; 42 }
|
||||
assertEquals(42, found.appId)
|
||||
assertEquals(SteamMatch.Certainty.NAME, found.certainty)
|
||||
assertEquals(2, searches)
|
||||
}
|
||||
|
||||
@Test fun anEarlierBuildsArtLookupIsKeptAsANameMatch() {
|
||||
val dir = tmp.newFolder("Hades")
|
||||
val exe = File(dir, "Hades.exe").apply { writeText("x") }
|
||||
val legacy = File(tmp.newFolder(), "steam-appid").apply { writeText("1145360") }
|
||||
val match = SteamMatch.resolve(app, dir.path, exe, "Hades", legacy = legacy) { fail("searched"); null }
|
||||
assertEquals(1145360, match.appId)
|
||||
assertEquals(SteamMatch.Certainty.NAME, match.certainty)
|
||||
}
|
||||
|
||||
@Test fun aStoreResultIsTakenOnlyWhenItsNameMatches() {
|
||||
// The first result is no longer taken for being first.
|
||||
assertNull(AddedGameArt.pickStoreResult("My Obscure Game", listOf("10" to "Totally Different", "11" to "Another One")))
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import java.util.zip.GZIPOutputStream
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Steam's app info asked anonymously: the answer read, batched, kept, and asked only when needed. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class SteamPicsTest {
|
||||
private val app get() = RuntimeEnvironment.getApplication()
|
||||
|
||||
/** Dragon's Dogma 2's app info as Steam's anonymous PICS answer carried it (recorded 2026-10-10). */
|
||||
private val recorded: String get() = javaClass.classLoader!!.getResource("steam-pics/app_2054970.vdf")!!.readText()
|
||||
|
||||
@After fun tearDown() { SteamAppInfo.pics = { SteamPics.lookup(it) } }
|
||||
|
||||
@Test fun theRecordedAnswerGivesTheSharedRedistAndTheName() {
|
||||
val info = SteamPics.infoFromKeyValues(recorded)!!
|
||||
assertEquals("Dragon's Dogma 2", info.name)
|
||||
assertEquals(listOf("228988"), info.sharedDepots)
|
||||
// VC++ 2019 for the Windows components.
|
||||
assertEquals(listOf("vcredist2019_dll" to "VC++ 2019"), WinCompSources.steamDepots(info.sharedDepots))
|
||||
}
|
||||
|
||||
private fun message(emsg: Int, body: ByteArray): ByteArray =
|
||||
ByteBuffer.allocate(8 + body.size).order(ByteOrder.LITTLE_ENDIAN).putInt(emsg or 0x80000000.toInt()).putInt(0).put(body).array()
|
||||
|
||||
@Test fun aProductInfoResponseInsideAGzippedMultiIsRead() {
|
||||
val appEntry = SteamPics.Proto().varint(1, 2054970).varint(2, 123).bytes(5, recorded.toByteArray() + byteArrayOf(0)).bytes()
|
||||
val response = SteamPics.Proto().bytes(1, appEntry).varint(2, 999).bytes()
|
||||
val inner = message(8904, response)
|
||||
val packed = ByteBuffer.allocate(4 + inner.size).order(ByteOrder.LITTLE_ENDIAN).putInt(inner.size).put(inner).array()
|
||||
val gz = ByteArrayOutputStream().also { o -> GZIPOutputStream(o).use { it.write(packed) } }.toByteArray()
|
||||
val multi = message(1, SteamPics.Proto().varint(1, packed.size.toLong()).bytes(2, gz).bytes())
|
||||
|
||||
val msgs = SteamPics.messages(multi)
|
||||
assertEquals(listOf(8904), msgs.map { it.first })
|
||||
val r = SteamPics.parseInfoResponse(msgs.single().third)
|
||||
assertEquals(listOf("228988"), r.apps.getValue(2054970).sharedDepots)
|
||||
assertEquals(listOf(999), r.unknown)
|
||||
assertFalse(r.pending)
|
||||
}
|
||||
|
||||
@Test fun requestsAreBatchedByTwentyFive() {
|
||||
val ids = (1..60).toList() + listOf(5, 6)
|
||||
val batches = SteamPics.batches(ids)
|
||||
assertEquals(listOf(25, 25, 10), batches.map { it.size })
|
||||
assertEquals((1..60).toList(), batches.flatten())
|
||||
}
|
||||
|
||||
@Test fun answersAreKeptAndAMissIsAskedAgainOnlyAfterAWeek() {
|
||||
val calls = ArrayList<List<Int>>()
|
||||
SteamAppInfo.pics = { ids -> calls.add(ids); mapOf(2054970 to SteamPics.infoFromKeyValues(recorded), 7 to null) }
|
||||
assertTrue(SteamAppInfo.lookup(app, listOf(2054970, 7)))
|
||||
// One connection for both.
|
||||
assertEquals(listOf(listOf(2054970, 7)), calls)
|
||||
assertEquals(listOf("228988"), SteamAppInfo.info(app, 2054970)!!.sharedDepots)
|
||||
assertNull(SteamAppInfo.info(app, 7))
|
||||
// Kept: nothing asked again (the miss waits a week).
|
||||
assertFalse(SteamAppInfo.lookup(app, listOf(2054970, 7)))
|
||||
assertEquals(1, calls.size)
|
||||
assertEquals("hit", SteamAppInfo.state(app, 2054970))
|
||||
assertEquals("miss", SteamAppInfo.state(app, 7))
|
||||
}
|
||||
|
||||
@Test fun noNetworkWhenTheClientsCacheHasTheApp() {
|
||||
// An appinfo.vdf with the app in it (built as in WinCompSourcesTest).
|
||||
val file = SteamAppInfo.appinfoFile(app)
|
||||
WinCompSourcesTest.writeAppinfo(file, 1145360)
|
||||
SteamAppInfo.pics = { fail("asked Steam"); null }
|
||||
assertTrue(SteamAppInfo.needLookup(app, listOf(1145360)).isEmpty())
|
||||
assertFalse(SteamAppInfo.lookup(app, listOf(1145360)))
|
||||
assertEquals(listOf("228986", "228990"), SteamAppInfo.info(app, 1145360)!!.sharedDepots)
|
||||
}
|
||||
|
||||
@Test fun anUnreachableSteamKeepsNothingAndTriesAgain() {
|
||||
SteamAppInfo.pics = { null }
|
||||
assertFalse(SteamAppInfo.lookup(app, listOf(42)))
|
||||
assertEquals(listOf(42), SteamAppInfo.needLookup(app, listOf(42)))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,465 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.WinComponents
|
||||
import com.droiddeck.launcher.stores.Store
|
||||
import com.droiddeck.launcher.stores.StoreGameSidecar
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.File
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TemporaryFolder
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
/** Each source's list of Windows components, how it maps to the catalog, and what turns on by itself. */
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [33])
|
||||
class WinCompSourcesTest {
|
||||
private val app get() = RuntimeEnvironment.getApplication()
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
|
||||
private fun game(folder: File?, source: String, appId: Int = 0x8123_4567.toInt(), library: String = Library.ADDED, name: String = "Game") =
|
||||
Library.SteamGame(appId, name, null, library, gameFiles = folder, source = source)
|
||||
|
||||
private fun sidecar(folder: File, store: Store, extra: Map<String, String>) =
|
||||
StoreGameSidecar(store, "1", "Game", exe = "Game.exe", extra = extra).write(folder)
|
||||
|
||||
private fun auto(findings: List<WinCompSources.Finding>) = findings.filter { it.auto }.map { it.component to it.origin }
|
||||
|
||||
// ---- mapping tables ----
|
||||
|
||||
@Test fun gogDependencyIdsMapToComponents() {
|
||||
assertEquals(listOf("vcredist2015"), WinCompSources.gog("MSVC2017_x64"))
|
||||
assertEquals(listOf("vcredist2022"), WinCompSources.gog("MSVC2022"))
|
||||
assertEquals(listOf("vcredist2013"), WinCompSources.gog("msvc2013"))
|
||||
assertEquals(listOf("d3dx9"), WinCompSources.gog("DirectX"))
|
||||
assertEquals(listOf("dotnet48"), WinCompSources.gog("DOTNET48"))
|
||||
assertEquals(listOf("dotnet472"), WinCompSources.gog("DotNet471"))
|
||||
assertEquals(listOf("physx"), WinCompSources.gog("PhysX"))
|
||||
assertEquals(listOf("oalinst"), WinCompSources.gog("OpenAL"))
|
||||
assertEquals(listOf("xna40"), WinCompSources.gog("XNA4"))
|
||||
assertEquals(emptyList<String>(), WinCompSources.gog("ISI"))
|
||||
}
|
||||
|
||||
@Test fun epicPrerequisitesMapToComponents() {
|
||||
assertEquals(listOf("vcredist2022", "d3dx9"), WinCompSources.epic("Engine/Extras/Redist/en-us/UEPrereqSetup_x64.exe", ""))
|
||||
assertEquals(listOf("vcredist2022", "d3dx9"), WinCompSources.epic("Engine/Extras/Redist/en-us/UE4PrereqSetup_x64.exe", "UE4 Prerequisites (x64)"))
|
||||
assertEquals(listOf("vcredist2022", "d3dx9"), WinCompSources.epic("", "UE4 Prerequisites (x64)"))
|
||||
assertEquals(listOf("vcredist2013"), WinCompSources.epic("Redist/vcredist/2013/vcredist_x64.exe", "Visual C++"))
|
||||
assertEquals(emptyList<String>(), WinCompSources.epic("Installers/LauncherHelper.exe", "Launcher Helper"))
|
||||
}
|
||||
|
||||
@Test fun steamworksSharedDepotsMapToComponents() {
|
||||
assertEquals(listOf("vcredist2015_dll" to "VC++ 2015", "d3dx9" to "DirectX (June 2010)"), WinCompSources.steamDepots(listOf("228986", "228990", "999")))
|
||||
}
|
||||
|
||||
// ---- per source ----
|
||||
|
||||
@Test fun steamTakesItsSharedDepotsAndTurnsThemOn() {
|
||||
val steamapps = tmp.newFolder("steamapps")
|
||||
val folder = File(steamapps, "common/Game").apply { mkdirs() }
|
||||
File(steamapps, "appmanifest_570.acf").writeText(
|
||||
"\"AppState\"\n{\n\t\"appid\"\t\t\"570\"\n\t\"SharedDepots\"\n\t{\n\t\t\"228986\"\t\t\"228980\"\n\t\t\"228990\"\t\t\"228980\"\n\t}\n}\n",
|
||||
)
|
||||
val findings = WinCompSources.forGame(app, game(folder, Library.SOURCE_STEAM, 570, library = "steam", name = "Dota"))
|
||||
assertEquals(listOf("vcredist2015_dll" to WinCompSources.Origin.STEAM, "d3dx9" to WinCompSources.Origin.STEAM), auto(findings))
|
||||
assertEquals(listOf("VC++ 2015", "Dota", "570"), findings.first().args)
|
||||
}
|
||||
|
||||
@Test fun gogTakesTheBuildsDependencies() {
|
||||
val folder = tmp.newFolder("GogGame")
|
||||
sidecar(folder, Store.GOG, mapOf(WinCompSources.GOG_DEPENDENCIES to "MSVC2017_x64,DirectX,ISI"))
|
||||
val findings = WinCompSources.forGame(app, game(folder, "gog"))
|
||||
assertEquals(listOf("vcredist2015" to WinCompSources.Origin.GOG, "d3dx9" to WinCompSources.Origin.GOG), auto(findings))
|
||||
// ISI is none of ours: a suggestion under its own name.
|
||||
assertEquals(listOf("ISI"), findings.filter { it.component == null }.map { it.original })
|
||||
}
|
||||
|
||||
@Test fun epicTakesTheManifestsPrerequisite() {
|
||||
val folder = tmp.newFolder("EpicGame")
|
||||
sidecar(folder, Store.EPIC, mapOf(WinCompSources.EPIC_PREREQ_PATH to "Engine/Extras/Redist/en-us/UEPrereqSetup_x64.exe", WinCompSources.EPIC_PREREQ_NAME to "UE Prerequisites"))
|
||||
assertEquals(listOf("vcredist2022" to WinCompSources.Origin.EPIC, "d3dx9" to WinCompSources.Origin.EPIC), auto(WinCompSources.forGame(app, game(folder, "epic"))))
|
||||
}
|
||||
|
||||
@Test fun amazonTakesFuelJsonsPostInstall() {
|
||||
val folder = tmp.newFolder("AmazonGame")
|
||||
sidecar(folder, Store.AMAZON, emptyMap())
|
||||
File(folder, "fuel.json").writeText(JSONObject()
|
||||
.put("Main", JSONObject().put("Command", "Game.exe"))
|
||||
.put("PostInstall", org.json.JSONArray()
|
||||
.put(JSONObject().put("Command", "_CommonRedist\\vcredist\\2015\\vc_redist.x64.exe").put("Args", org.json.JSONArray().put("/q")))
|
||||
.put(JSONObject().put("Command", "_CommonRedist\\DirectX\\Jun2010\\DXSETUP.exe"))
|
||||
.put(JSONObject().put("Command", "Tools\\Activate.exe")))
|
||||
.toString())
|
||||
val findings = WinCompSources.forGame(app, game(folder, "amazon"))
|
||||
assertEquals(listOf("vcredist2015" to WinCompSources.Origin.AMAZON, "d3dx9" to WinCompSources.Origin.AMAZON), auto(findings))
|
||||
assertEquals(listOf("Activate.exe"), findings.filter { it.component == null }.map { it.original })
|
||||
}
|
||||
|
||||
// ---- Custom: certainty decides ----
|
||||
|
||||
private fun appinfo(file: File, appId: Int) = writeAppinfo(file, appId)
|
||||
|
||||
companion object {
|
||||
/** A version 29 appinfo.vdf with one app, whose depots point at two Steamworks Shared ones. */
|
||||
fun writeAppinfo(file: File, appId: Int) {
|
||||
val strings = listOf("appinfo", "common", "name", "depots", "228986", "depotfromapp", "228990", "1001", "maxsize")
|
||||
fun key(s: String) = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(strings.indexOf(s)).array()
|
||||
val kv = ByteArrayOutputStream().apply {
|
||||
fun map(k: String) { write(0x00); write(key(k)) }
|
||||
fun str(k: String, v: String) { write(0x01); write(key(k)); write(v.toByteArray()); write(0) }
|
||||
fun end() = write(0x08)
|
||||
map("appinfo"); map("common"); str("name", "Some Game"); end()
|
||||
map("depots"); map("228986"); str("depotfromapp", "228980"); end(); map("228990"); str("depotfromapp", "228980"); end()
|
||||
map("1001"); str("maxsize", "1"); end(); end(); end(); end()
|
||||
}.toByteArray()
|
||||
val body = ByteArray(60) + kv
|
||||
val table = ByteArrayOutputStream().apply {
|
||||
write(ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putInt(strings.size).array())
|
||||
strings.forEach { write(it.toByteArray()); write(0) }
|
||||
}.toByteArray()
|
||||
val head = ByteBuffer.allocate(16).order(ByteOrder.LITTLE_ENDIAN).putInt(0x07564429).putInt(1)
|
||||
val entries = ByteBuffer.allocate(8 + body.size + 4).order(ByteOrder.LITTLE_ENDIAN).putInt(appId).putInt(body.size).put(body).putInt(0).array()
|
||||
head.putLong(16L + entries.size)
|
||||
file.parentFile!!.mkdirs()
|
||||
file.writeBytes(head.array() + entries + table)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun steamsAppInfoIsReadForAnAppWithoutAManifest() {
|
||||
val file = File(tmp.newFolder(), "appinfo.vdf")
|
||||
appinfo(file, 1145360)
|
||||
val info = SteamAppInfo.read(file, 1145360)!!
|
||||
assertEquals("Some Game", info.name)
|
||||
assertEquals(listOf("228986", "228990"), info.sharedDepots)
|
||||
assertEquals(null, SteamAppInfo.read(file, 1))
|
||||
}
|
||||
|
||||
@Test fun customWithAnAppidInItsFilesIsCertainAndOnByItself() {
|
||||
appinfo(SteamAppInfo.appinfoFile(app), 1145360)
|
||||
val folder = tmp.newFolder("Hades")
|
||||
File(folder, "steam_appid.txt").writeText("1145360")
|
||||
val exe = File(folder, "Hades.exe").apply { writeText("x") }
|
||||
assertEquals(SteamMatch.Certainty.FILES, SteamMatch.resolve(app, folder.path, exe, "Hades").certainty)
|
||||
val findings = WinCompSources.forGame(app, game(folder, Library.ADDED, name = "Hades"))
|
||||
assertEquals(listOf("vcredist2015_dll" to WinCompSources.Origin.STEAM, "d3dx9" to WinCompSources.Origin.STEAM), auto(findings))
|
||||
}
|
||||
|
||||
@Test fun customMatchedByNameIsOnlyRecommended() {
|
||||
appinfo(SteamAppInfo.appinfoFile(app), 1145360)
|
||||
val folder = tmp.newFolder("Hades Named")
|
||||
val exe = File(folder, "Hades.exe").apply { writeText("x") }
|
||||
assertEquals(SteamMatch.Certainty.NAME, SteamMatch.resolve(app, folder.path, exe, "Hades") { 1145360 }.certainty)
|
||||
val findings = WinCompSources.forGame(app, game(folder, Library.ADDED, name = "Hades"))
|
||||
assertTrue(auto(findings).isEmpty())
|
||||
assertEquals(listOf("vcredist2015_dll", "d3dx9"), findings.filter { it.origin == WinCompSources.Origin.STEAM_NAME }.map { it.component })
|
||||
}
|
||||
|
||||
@Test fun customWithNoMatchHasTheFolderScanOnly() {
|
||||
val folder = tmp.newFolder("Unknown")
|
||||
File(folder, "_CommonRedist/vcredist/2013").mkdirs()
|
||||
File(folder, "_CommonRedist/vcredist/2013/vcredist_x64.exe").writeText("x")
|
||||
val findings = WinCompSources.forGame(app, game(folder, Library.ADDED))
|
||||
assertTrue(auto(findings).isEmpty())
|
||||
assertEquals(listOf("vcredist2013" to WinCompSources.Origin.FOLDER), findings.map { it.component to it.origin })
|
||||
}
|
||||
|
||||
// ---- what turns on, and the user's switches ----
|
||||
|
||||
private fun ready(name: String) = WinComponents.Component(name, "", "", "ready", emptyList(),
|
||||
listOf(WinComponents.Step("override_dll", JSONObject().put("dll", "x").put("type", "native"))))
|
||||
|
||||
@Test fun onlyAutoFindingsThatInstallHereTurnOn() {
|
||||
val catalog = listOf(ready("vcredist2015_dll"), ready("vcredist2015"), ready("d3dx9"),
|
||||
WinComponents.Component("physx", "", "", "broken", emptyList(), emptyList())).associateBy { it.name }
|
||||
val findings = listOf(
|
||||
WinCompSources.Finding("vcredist2015", "MSVC2017", WinCompSources.Origin.GOG, true),
|
||||
WinCompSources.Finding("physx", "PhysX", WinCompSources.Origin.GOG, true),
|
||||
WinCompSources.Finding("d3dx9", "d3dx9_43.dll", WinCompSources.Origin.FOLDER, false),
|
||||
)
|
||||
val picks = AutoComponents.autoPicks(findings, catalog)
|
||||
// The detector's name installs as its DLL twin; physx cannot install; the folder scan only recommends.
|
||||
assertEquals(listOf("vcredist2015_dll"), picks.keys.toList())
|
||||
assertEquals("GOG", picks.getValue("vcredist2015_dll").kind)
|
||||
}
|
||||
|
||||
@Test fun theUsersSwitchWinsOverAnAutoPickEitherWay() {
|
||||
val key = "12345"
|
||||
WinComponents.setAuto(app, key, mapOf("d3dx9" to WinComponents.AutoReason("GOG", listOf("DirectX")), "xact" to WinComponents.AutoReason("GOG", listOf("XACT"))))
|
||||
assertEquals(listOf("d3dx9", "xact"), WinComponents.picks(app, key))
|
||||
WinComponents.setUser(app, key, "d3dx9", false)
|
||||
WinComponents.setUser(app, key, "oalinst_dll", true)
|
||||
assertEquals(listOf("xact", "oalinst_dll"), WinComponents.picks(app, key))
|
||||
// A new automatic list keeps the user's switches.
|
||||
WinComponents.setAuto(app, key, mapOf("d3dx9" to WinComponents.AutoReason("GOG", listOf("DirectX"))))
|
||||
assertEquals(listOf("oalinst_dll"), WinComponents.picks(app, key))
|
||||
assertEquals(mapOf("d3dx9" to false, "oalinst_dll" to true), WinComponents.selection(app, key).user)
|
||||
// What the launch reads stays version 1 with the effective list.
|
||||
val text = File(app.filesDir, "wincomponents.json").readText()
|
||||
assertEquals(1, JSONObject(text).getInt("version"))
|
||||
assertEquals("oalinst_dll", JSONObject(text).getJSONObject("games").getJSONArray(key).getString(0))
|
||||
}
|
||||
|
||||
@Test fun picksFromBeforeOverridesAreTheUsersOwn() {
|
||||
File(app.filesDir, "wincomponents.json").writeText("""{"version":1,"games":{"777":["d3dx9","xact"]}}""")
|
||||
assertEquals(mapOf("d3dx9" to true, "xact" to true), WinComponents.selection(app, "777").user)
|
||||
assertEquals(listOf("d3dx9", "xact"), WinComponents.picks(app, "777"))
|
||||
}
|
||||
|
||||
@Test fun artAndComponentsShareOneSteamMatch() {
|
||||
val folder = tmp.newFolder("Shared")
|
||||
val exe = File(folder, "Shared.exe").apply { writeText("x") }
|
||||
val art = AddedGames.Game(folder, "Shared", exe, "/g/Shared.exe", "/g", 0x81234567L, 0L, emptyList())
|
||||
// The art fetch finds it by name ...
|
||||
val match = AddedGameArt.steamMatch(app, art) { 4242 }
|
||||
assertEquals(4242, match.appId)
|
||||
// ... and the components read the same record.
|
||||
assertEquals(4242, SteamMatch.get(app, folder.path)!!.appId)
|
||||
assertEquals(SteamMatch.Certainty.NAME, SteamMatch.get(app, folder.path)!!.certainty)
|
||||
assertFalse(WinCompSources.forGame(app, game(folder, Library.ADDED)).any { it.auto })
|
||||
}
|
||||
|
||||
private val catalog3 = listOf(ready("d3dx9"), ready("vcredist2013_dll"), ready("vcredist2019_dll"))
|
||||
private val installedNow = mutableSetOf<String>()
|
||||
private val downloaded = ArrayList<String>()
|
||||
private var session = false
|
||||
private var gameUp = false
|
||||
|
||||
private fun payback(): Library.SteamGame {
|
||||
val steamapps = tmp.newFolder("steamapps")
|
||||
val folder = File(steamapps, "common/Payback").apply { mkdirs() }
|
||||
File(steamapps, "appmanifest_1262580.acf").writeText(
|
||||
"\"AppState\"\n{\n\t\"SharedDepots\"\n\t{\n\t\t\"228985\"\t\t\"228980\"\n\t\t\"228988\"\t\t\"228980\"\n\t\t\"228990\"\t\t\"228980\"\n\t}\n}\n",
|
||||
)
|
||||
return game(folder, Library.SOURCE_STEAM, 1262580, library = "steam", name = "Need for Speed Payback")
|
||||
}
|
||||
|
||||
private fun <T> withFakeWorker(block: () -> T): T {
|
||||
AutoComponents.catalogOf = { _, _ -> catalog3 }
|
||||
AutoComponents.installedOf = { installedNow.toSet() }
|
||||
AutoComponents.installer = { _, c, _, _ -> downloaded.add(c.name); installedNow.add(c.name); null }
|
||||
AutoComponents.sessionRunning = { session }
|
||||
AutoComponents.unfavourable = { false }
|
||||
AutoComponents.online = { true }
|
||||
AutoComponents.gameRunning = { gameUp }
|
||||
AutoComponents.runNow = true
|
||||
try {
|
||||
return block()
|
||||
} finally {
|
||||
AutoComponents.runNow = false
|
||||
AutoComponents.catalogOf = { c, network -> if (network) WinComponents.fetch(c) else WinComponents.cached(c) }
|
||||
AutoComponents.installedOf = { WinComponents.installedIds(it).toSet() }
|
||||
AutoComponents.installer = { c, comp, all, progress -> WinComponents.install(c, comp, all, progress) }
|
||||
AutoComponents.sessionRunning = { com.droiddeck.launcher.session.SessionState.running }
|
||||
AutoComponents.online = { c -> AutoComponents.isOnline(c) }
|
||||
AutoComponents.gameRunning = { false }
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun aSteamListedComponentNotYetDownloadedTurnsOnWhenItLands() = withFakeWorker {
|
||||
installedNow.add("d3dx9")
|
||||
val game = payback()
|
||||
// The user had switched one off: it is never downloaded for an automatic pick.
|
||||
WinComponents.setUser(app, "1262580", "vcredist2019_dll", false)
|
||||
AutoComponents.queueGame(app, game, asked = true)
|
||||
assertEquals(listOf("vcredist2013_dll"), downloaded)
|
||||
val sel = WinComponents.selection(app, "1262580")
|
||||
assertEquals(setOf("d3dx9", "vcredist2013_dll", "vcredist2019_dll"), sel.auto.keys)
|
||||
assertEquals(setOf("d3dx9", "vcredist2013_dll"), WinComponents.picks(app, "1262580").toSet())
|
||||
assertEquals("STEAM", sel.auto.getValue("vcredist2013_dll").kind)
|
||||
// The list is kept for the page.
|
||||
assertEquals(3, AutoComponents.cachedFindings(app, "1262580")!!.count { it.auto })
|
||||
}
|
||||
|
||||
@Test fun theQueueWaitsWhileASessionRunsAndResumesAfter() = withFakeWorker {
|
||||
val game = payback()
|
||||
session = true
|
||||
AutoComponents.queueGame(app, game, asked = true)
|
||||
assertTrue(downloaded.isEmpty())
|
||||
assertEquals(listOf("g:1262580"), AutoComponents.queued())
|
||||
session = false
|
||||
AutoComponents.drain(app)
|
||||
assertEquals(listOf("vcredist2013_dll", "vcredist2019_dll", "d3dx9"), downloaded)
|
||||
assertTrue(AutoComponents.queued().isEmpty())
|
||||
}
|
||||
|
||||
@Test fun aJobNobodyAskedForWaitsOnAnUnfavourableDevice() = withFakeWorker {
|
||||
val game = payback()
|
||||
AutoComponents.unfavourable = { true }
|
||||
AutoComponents.queueGame(app, game, asked = false)
|
||||
assertTrue(downloaded.isEmpty())
|
||||
// The user opens the page: asked for, it goes.
|
||||
AutoComponents.queueGame(app, game, asked = true)
|
||||
assertEquals(3, downloaded.size)
|
||||
}
|
||||
|
||||
@Test fun aGameAndAComponentAreQueuedOnceEach() = withFakeWorker {
|
||||
val first = payback()
|
||||
val other = game(first.gameFiles, Library.SOURCE_STEAM, 1262580, library = "steam", name = "Same app again")
|
||||
session = true
|
||||
AutoComponents.queueGame(app, first, asked = false)
|
||||
AutoComponents.queueGame(app, other, asked = false)
|
||||
assertEquals(listOf("g:1262580"), AutoComponents.queued())
|
||||
session = false
|
||||
AutoComponents.drain(app)
|
||||
// Each component once, though two games could want it.
|
||||
assertEquals(downloaded.distinct(), downloaded)
|
||||
}
|
||||
|
||||
@Test fun aDownloadWaitsForANetwork() = withFakeWorker {
|
||||
val game = payback()
|
||||
AutoComponents.online = { false }
|
||||
AutoComponents.queueGame(app, game, asked = true)
|
||||
// The list is worked out offline; the downloads wait.
|
||||
assertTrue(downloaded.isEmpty())
|
||||
assertTrue(AutoComponents.queued().all { it.startsWith("c:") })
|
||||
AutoComponents.online = { true }
|
||||
AutoComponents.drain(app)
|
||||
assertEquals(3, downloaded.size)
|
||||
}
|
||||
|
||||
// ---- older store installs: the list read once from the store ----
|
||||
|
||||
@Test fun anOlderGogInstallGetsItsListFromTheStoreOnce() {
|
||||
val folder = tmp.newFolder("DOOM I Enhanced")
|
||||
StoreGameSidecar(Store.GOG, "2015545325", "DOOM", exe = "DOOM.exe", installVersion = "123").write(folder)
|
||||
var fetches = 0
|
||||
StoreListBackfill.fetcher = StoreListBackfill.Fetcher { _, sc -> fetches++; assertEquals("123", sc.installVersion); mapOf(WinCompSources.GOG_DEPENDENCIES to "MSVC2019_x64,DirectX") }
|
||||
AutoComponents.online = { true }
|
||||
try {
|
||||
assertTrue(StoreListBackfill.fill(app, folder))
|
||||
assertEquals("MSVC2019_x64,DirectX", StoreGameSidecar.read(folder)!!.extra[WinCompSources.GOG_DEPENDENCIES])
|
||||
// Kept like a fresh install: not fetched again.
|
||||
assertFalse(StoreListBackfill.fill(app, folder))
|
||||
assertEquals(1, fetches)
|
||||
assertEquals(listOf("vcredist2019" to WinCompSources.Origin.GOG, "d3dx9" to WinCompSources.Origin.GOG), auto(WinCompSources.forGame(app, game(folder, "gog"))))
|
||||
} finally {
|
||||
StoreListBackfill.fetcher = StoreListBackfill.storeFetcher
|
||||
AutoComponents.online = { c -> AutoComponents.isOnline(c) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun anOlderEpicInstallGetsItsPrerequisite() {
|
||||
val folder = tmp.newFolder("DOOMBLADE")
|
||||
StoreGameSidecar(Store.EPIC, "abc", "DOOMBLADE", exe = "DOOMBLADE.exe", extra = mapOf("namespace" to "ns", "catalogItemId" to "cat")).write(folder)
|
||||
StoreListBackfill.fetcher = StoreListBackfill.Fetcher { _, _ ->
|
||||
mapOf(WinCompSources.EPIC_PREREQ_NAME to "UE4 Prerequisites (x64)", WinCompSources.EPIC_PREREQ_PATH to "Engine/Extras/Redist/en-us/UE4PrereqSetup_x64.exe")
|
||||
}
|
||||
AutoComponents.online = { true }
|
||||
try {
|
||||
assertTrue(StoreListBackfill.fill(app, folder))
|
||||
assertEquals("ns", StoreGameSidecar.read(folder)!!.extra["namespace"])
|
||||
assertEquals(listOf("vcredist2022" to WinCompSources.Origin.EPIC, "d3dx9" to WinCompSources.Origin.EPIC), auto(WinCompSources.forGame(app, game(folder, "epic"))))
|
||||
} finally {
|
||||
StoreListBackfill.fetcher = StoreListBackfill.storeFetcher
|
||||
AutoComponents.online = { c -> AutoComponents.isOnline(c) }
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun aFailedFetchWaitsAWeekAndOfflineIsNoTry() {
|
||||
val folder = tmp.newFolder("Metalstorm")
|
||||
StoreGameSidecar(Store.EPIC, "m", "Metalstorm", exe = "M.exe").write(folder)
|
||||
var fetches = 0
|
||||
StoreListBackfill.fetcher = StoreListBackfill.Fetcher { _, _ -> fetches++; null }
|
||||
try {
|
||||
AutoComponents.online = { false }
|
||||
assertFalse(StoreListBackfill.fill(app, folder))
|
||||
assertEquals(0, fetches)
|
||||
AutoComponents.online = { true }
|
||||
assertFalse(StoreListBackfill.fill(app, folder))
|
||||
assertFalse(StoreListBackfill.fill(app, folder))
|
||||
// Signed out or the store refused: once, then a week's rest; the sidecar is untouched.
|
||||
assertEquals(1, fetches)
|
||||
assertTrue(StoreListBackfill.needs(StoreGameSidecar.read(folder)!!))
|
||||
} finally {
|
||||
StoreListBackfill.fetcher = StoreListBackfill.storeFetcher
|
||||
AutoComponents.online = { c -> AutoComponents.isOnline(c) }
|
||||
}
|
||||
}
|
||||
|
||||
// ---- apps the client never saw ----
|
||||
|
||||
@Test fun anAppTheClientLearnsLaterIsReadOnceItsCacheIsWritten() {
|
||||
val file = SteamAppInfo.appinfoFile(app)
|
||||
appinfo(file, 1)
|
||||
file.setLastModified(1_000_000_000_000L)
|
||||
assertEquals(null, SteamAppInfo.info(app, 1145360))
|
||||
// Unchanged file: the miss stands (no re-read).
|
||||
assertEquals(null, SteamAppInfo.info(app, 1145360))
|
||||
// The client wrote its cache with the app in it: read at once, not a week later.
|
||||
appinfo(file, 1145360)
|
||||
file.setLastModified(1_000_000_100_000L)
|
||||
assertEquals(listOf("228986", "228990"), SteamAppInfo.info(app, 1145360)!!.sharedDepots)
|
||||
}
|
||||
|
||||
@Test fun unitysManagedFolderIsNotADotNetRequirement() {
|
||||
val folder = tmp.newFolder("DOOMBLADE")
|
||||
File(folder, "DOOMBLADE_Data/Managed").mkdirs()
|
||||
File(folder, "DOOMBLADE_Data/Managed/com.rlabrecque.steamworks.net.dll").writeText("x")
|
||||
File(folder, "DOOMBLADE_Data/Managed/System.Net.Http.dll").writeText("x")
|
||||
assertTrue(DependencyDetector.detect(folder).isEmpty())
|
||||
}
|
||||
|
||||
@Test fun aListReadWholeIsNotFetchedAgainButAnUnmarkedEmptyOneIs() {
|
||||
val read = tmp.newFolder("Read")
|
||||
StoreGameSidecar(Store.EPIC, "a", "A", exe = "A.exe", extra = mapOf(WinCompSources.EPIC_PREREQ_NAME to "", WinCompSources.EPIC_PREREQ_PATH to "", WinCompSources.LIST_READ to "1")).write(read)
|
||||
val older = tmp.newFolder("Older")
|
||||
StoreGameSidecar(Store.EPIC, "b", "B", exe = "B.exe", extra = mapOf(WinCompSources.EPIC_PREREQ_NAME to "", WinCompSources.EPIC_PREREQ_PATH to "")).write(older)
|
||||
assertFalse(StoreListBackfill.needs(StoreGameSidecar.read(read)!!))
|
||||
assertTrue(StoreListBackfill.needs(StoreGameSidecar.read(older)!!))
|
||||
}
|
||||
|
||||
// ---- a game installed during the session ----
|
||||
|
||||
private fun manifest(dir: File, appId: Int, state: Int, time: Long) =
|
||||
File(dir, "appmanifest_$appId.acf").apply {
|
||||
writeText("\"AppState\"\n{\n\t\"appid\"\t\t\"$appId\"\n\t\"name\"\t\t\"Audiosurf\"\n\t\"installdir\"\t\t\"Audiosurf\"\n\t\"StateFlags\"\t\t\"$state\"\n}\n")
|
||||
setLastModified(time)
|
||||
}
|
||||
|
||||
@Test fun aManifestTurningInstalledIsNoticedOnce() {
|
||||
val steamapps = tmp.newFolder("steamapps")
|
||||
manifest(steamapps, 12900, 1026, 1_000_000L)
|
||||
manifest(steamapps, 570, 4, 1_000_000L)
|
||||
val watch = SessionInstalls.Watch { listOf(steamapps) }
|
||||
// What was there when the session started is not new.
|
||||
assertTrue(watch.poll().isEmpty())
|
||||
assertTrue(watch.poll().isEmpty())
|
||||
manifest(steamapps, 12900, 4, 2_000_000L)
|
||||
manifest(steamapps, 440, 4, 2_000_000L)
|
||||
val done = watch.poll()
|
||||
assertEquals(setOf(12900, 440), done.map { it.appId }.toSet())
|
||||
assertEquals(File(steamapps, "common/Audiosurf"), SessionInstalls.gameOf(done.first { it.appId == 12900 }).gameFiles)
|
||||
assertTrue(watch.poll().isEmpty())
|
||||
}
|
||||
|
||||
@Test fun anInSessionJobWaitsWhileAGameRunsThenReadiesItsComponents() = withFakeWorker {
|
||||
val installed = payback()
|
||||
session = true
|
||||
gameUp = true
|
||||
AutoComponents.queueInSession(app, installed)
|
||||
assertTrue(downloaded.isEmpty())
|
||||
// Other work stays paused in the session.
|
||||
val other = game(tmp.newFolder("Other"), Library.ADDED)
|
||||
AutoComponents.queueGame(app, other, asked = false)
|
||||
gameUp = false
|
||||
AutoComponents.drain(app)
|
||||
assertEquals(3, downloaded.size)
|
||||
assertEquals(setOf("d3dx9", "vcredist2013_dll", "vcredist2019_dll"), WinComponents.picks(app, "1262580").toSet())
|
||||
// Nothing left pending for the launch to wait on; the other game still waits for the session's end.
|
||||
assertEquals(0, AutoComponents.Pending.read(app).length())
|
||||
assertEquals(listOf("g:${AutoComponents.appKey(other)}"), AutoComponents.queued())
|
||||
session = false
|
||||
AutoComponents.drain(app)
|
||||
assertTrue(AutoComponents.queued().isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,882 @@
|
||||
"appinfo"
|
||||
{
|
||||
"appid" "2054970"
|
||||
"common"
|
||||
{
|
||||
"name" "Dragon's Dogma 2"
|
||||
"type" "Game"
|
||||
"releasestate" "released"
|
||||
"oslist" "windows"
|
||||
"osarch" "64"
|
||||
"osextended" ""
|
||||
"icon" "f3d469e0d7a0e25ddd13db8ebf08f1c24d6478b0"
|
||||
"clienticon" "a8eaa474f143bfdab634907e034ea6fa47086ccb"
|
||||
"clienttga" "0c02d7c5b9790c444597578019b02937234b90d2"
|
||||
"languages"
|
||||
{
|
||||
"english" "1"
|
||||
"german" "1"
|
||||
"french" "1"
|
||||
"italian" "1"
|
||||
"koreana" "1"
|
||||
"spanish" "1"
|
||||
"schinese" "1"
|
||||
"tchinese" "1"
|
||||
"russian" "1"
|
||||
"japanese" "1"
|
||||
"portuguese" "1"
|
||||
"polish" "1"
|
||||
"arabic" "1"
|
||||
"brazilian" "1"
|
||||
"latam" "1"
|
||||
}
|
||||
"eulas"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"id" "2054970_eula_0"
|
||||
"name" "Dragon's Dogma 2 EULA"
|
||||
"url" "https://store.steampowered.com//eula/2054970_eula_0"
|
||||
"version" "3"
|
||||
}
|
||||
}
|
||||
"content_descriptors"
|
||||
{
|
||||
"0" "1"
|
||||
"1" "5"
|
||||
}
|
||||
"steam_deck_compatibility"
|
||||
{
|
||||
"category" "1"
|
||||
"steamos_compatibility" "2"
|
||||
"steam_machine_compatibility" "3"
|
||||
"test_timestamp" "1784764800"
|
||||
"tested_build_id" "2054970"
|
||||
"tests"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"display" "2"
|
||||
"token" "#SteamDeckVerified_TestResult_UnsupportedGraphicsPerformance"
|
||||
}
|
||||
}
|
||||
"steam_machine_tests"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"display" "4"
|
||||
"token" "#SteamMachine_TestResult_DefaultControllerConfigFullyFunctional"
|
||||
}
|
||||
"1"
|
||||
{
|
||||
"display" "4"
|
||||
"token" "#SteamMachine_TestResult_ControllerGlyphsMatchDevice"
|
||||
}
|
||||
"2"
|
||||
{
|
||||
"display" "4"
|
||||
"token" "#SteamMachine_TestResult_DefaultConfigurationIsPerformant"
|
||||
}
|
||||
}
|
||||
"steamos_tests"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"display" "3"
|
||||
"token" "#SteamOS_TestResult_GameStartupFunctional"
|
||||
}
|
||||
"1"
|
||||
{
|
||||
"display" "1"
|
||||
"token" "#SteamOS_TestResult_ExternalControllersNotSupportedPrimaryPlayer"
|
||||
}
|
||||
}
|
||||
"configuration"
|
||||
{
|
||||
"supported_input" "other"
|
||||
"requires_manual_keyboard_invoke" "0"
|
||||
"requires_non_controller_launcher_nav" "0"
|
||||
"primary_player_is_controller_slot_0" "1"
|
||||
"non_deck_display_glyphs" "0"
|
||||
"small_text" "1"
|
||||
"requires_internet_for_setup" "0"
|
||||
"requires_internet_for_singleplayer" "0"
|
||||
"recommended_runtime" "proton-11.0-beta"
|
||||
"requires_h264" "0"
|
||||
"requires_voice_files" "0"
|
||||
"gamescope_frame_limiter_not_supported" "0"
|
||||
"hdr_support" "0"
|
||||
"requires_proton_soundfonts" "0"
|
||||
}
|
||||
}
|
||||
"metacritic_name" "Dragon's Dogma 2"
|
||||
"controllertagwizard" "1"
|
||||
"controller_support" "partial"
|
||||
"small_capsule"
|
||||
{
|
||||
"english" "capsule_231x87.jpg"
|
||||
}
|
||||
"header_image"
|
||||
{
|
||||
"english" "header.jpg"
|
||||
}
|
||||
"library_assets"
|
||||
{
|
||||
"library_capsule" "en"
|
||||
"library_hero" "en"
|
||||
"library_logo" "en"
|
||||
"library_header" "en"
|
||||
"logo_position"
|
||||
{
|
||||
"pinned_position" "BottomLeft"
|
||||
"width_pct" "23.186781828785556"
|
||||
"height_pct" "41.66700658287976"
|
||||
}
|
||||
}
|
||||
"library_assets_full"
|
||||
{
|
||||
"library_capsule"
|
||||
{
|
||||
"image"
|
||||
{
|
||||
"english" "library_600x900.jpg"
|
||||
}
|
||||
"image2x"
|
||||
{
|
||||
"english" "library_600x900_2x.jpg"
|
||||
}
|
||||
}
|
||||
"library_hero"
|
||||
{
|
||||
"image"
|
||||
{
|
||||
"english" "library_hero.jpg"
|
||||
}
|
||||
"image2x"
|
||||
{
|
||||
"english" "library_hero_2x.jpg"
|
||||
}
|
||||
}
|
||||
"library_logo"
|
||||
{
|
||||
"logo_position"
|
||||
{
|
||||
"pinned_position" "BottomLeft"
|
||||
"width_pct" "23.186781828785556"
|
||||
"height_pct" "41.66700658287976"
|
||||
}
|
||||
"image"
|
||||
{
|
||||
"english" "logo.png"
|
||||
}
|
||||
"image2x"
|
||||
{
|
||||
"english" "logo_2x.png"
|
||||
}
|
||||
}
|
||||
"library_header"
|
||||
{
|
||||
"image"
|
||||
{
|
||||
"english" "library_header.jpg"
|
||||
}
|
||||
}
|
||||
}
|
||||
"store_asset_mtime" "1753923539"
|
||||
"associations"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"type" "developer"
|
||||
"name" "CAPCOM Co., Ltd."
|
||||
}
|
||||
"1"
|
||||
{
|
||||
"type" "publisher"
|
||||
"name" "CAPCOM Co., Ltd."
|
||||
}
|
||||
"2"
|
||||
{
|
||||
"type" "franchise"
|
||||
"name" "Dragon's Dogma"
|
||||
}
|
||||
}
|
||||
"primary_genre" "3"
|
||||
"genres"
|
||||
{
|
||||
"0" "1"
|
||||
"1" "3"
|
||||
}
|
||||
"category"
|
||||
{
|
||||
"category_2" "1"
|
||||
"category_18" "1"
|
||||
"category_55" "1"
|
||||
"category_57" "1"
|
||||
"category_60" "1"
|
||||
"category_22" "1"
|
||||
"category_29" "1"
|
||||
"category_23" "1"
|
||||
"category_61" "1"
|
||||
"category_45" "1"
|
||||
"category_46" "1"
|
||||
"category_62" "1"
|
||||
}
|
||||
"supported_languages"
|
||||
{
|
||||
"english"
|
||||
{
|
||||
"supported" "true"
|
||||
"full_audio" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
"french"
|
||||
{
|
||||
"supported" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
"italian"
|
||||
{
|
||||
"supported" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
"german"
|
||||
{
|
||||
"supported" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
"spanish"
|
||||
{
|
||||
"supported" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
"arabic"
|
||||
{
|
||||
"supported" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
"latam"
|
||||
{
|
||||
"supported" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
"brazilian"
|
||||
{
|
||||
"supported" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
"russian"
|
||||
{
|
||||
"supported" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
"schinese"
|
||||
{
|
||||
"supported" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
"tchinese"
|
||||
{
|
||||
"supported" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
"japanese"
|
||||
{
|
||||
"supported" "true"
|
||||
"full_audio" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
"koreana"
|
||||
{
|
||||
"supported" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
"polish"
|
||||
{
|
||||
"supported" "true"
|
||||
"subtitles" "true"
|
||||
}
|
||||
}
|
||||
"steam_release_date" "1711065600"
|
||||
"metacritic_score" "88"
|
||||
"metacritic_fullurl" "https://www.metacritic.com/game/pc/dragons-dogma-2?ftag=MCD-06-10aaa1f"
|
||||
"community_visible_stats" "1"
|
||||
"community_hub_visible" "1"
|
||||
"gameid" "2054970"
|
||||
"content_descriptors_including_dlc"
|
||||
{
|
||||
"0" "1"
|
||||
"1" "5"
|
||||
}
|
||||
"store_tags"
|
||||
{
|
||||
"0" "1695"
|
||||
"1" "122"
|
||||
"2" "4231"
|
||||
"3" "1684"
|
||||
"4" "4182"
|
||||
"5" "19"
|
||||
"6" "21"
|
||||
"7" "4747"
|
||||
"8" "4046"
|
||||
"9" "4057"
|
||||
"10" "4106"
|
||||
"11" "4172"
|
||||
"12" "4608"
|
||||
"13" "1697"
|
||||
"14" "3968"
|
||||
"15" "3854"
|
||||
"16" "3859"
|
||||
"17" "7926"
|
||||
"18" "4191"
|
||||
"19" "6730"
|
||||
}
|
||||
"review_score" "5"
|
||||
"review_percentage" "63"
|
||||
}
|
||||
"extended"
|
||||
{
|
||||
"additional_dependencies"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"src_os" "windows"
|
||||
"dest_os" "linux"
|
||||
"h264" "1"
|
||||
}
|
||||
}
|
||||
"developer" "CAPCOM Co., Ltd."
|
||||
"publisher" "CAPCOM Co., Ltd."
|
||||
"homepage" "https://www.dragonsdogma.com/2da/"
|
||||
"aliases" "ドグマ,ドグマ2,DD2,龙信2,ドラゴンズドグマ,龙之信条"
|
||||
"listofdlc" "2581000,2593170,2593180,2593190,2593200,2593210,2593220,2593230,2593240,2593250,2593260,2593270,2593280,2593290,2694820,2694830,2694840,2694850,2757060,2757080,2757090,2757120,2757130,2757140,2757170"
|
||||
"dlcavailableonstore" "1"
|
||||
}
|
||||
"config"
|
||||
{
|
||||
"installdir" "Dragons Dogma 2"
|
||||
"launch"
|
||||
{
|
||||
"0"
|
||||
{
|
||||
"executable" "DD2.exe"
|
||||
}
|
||||
}
|
||||
"steamcontrollertemplateindex" "2"
|
||||
"steamdecktouchscreen" "1"
|
||||
"steamconfigurator3rdpartynative" "65534"
|
||||
}
|
||||
"depots"
|
||||
{
|
||||
"baselanguages" "english,german,french,italian,koreana,spanish,schinese,tchinese,russian,japanese,polish,arabic,brazilian,latam"
|
||||
"228988"
|
||||
{
|
||||
"config"
|
||||
{
|
||||
"oslist" "windows"
|
||||
}
|
||||
"depotfromapp" "228980"
|
||||
"sharedinstall" "1"
|
||||
}
|
||||
"2054971"
|
||||
{
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "3438547366935427234"
|
||||
"size" "154689650"
|
||||
"download" "49702016"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2054972"
|
||||
{
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "2497482018950166086"
|
||||
"size" "154689650"
|
||||
"download" "49702016"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2054973"
|
||||
{
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "5096395230065303989"
|
||||
"size" "69392135623"
|
||||
"download" "68155907184"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2054974"
|
||||
{
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "4433477873043462508"
|
||||
"size" "69392135912"
|
||||
"download" "68163333808"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2581000"
|
||||
{
|
||||
"dlcappid" "2581000"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "6150746279967323915"
|
||||
"size" "732"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2593170"
|
||||
{
|
||||
"dlcappid" "2593170"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "810106481153607467"
|
||||
"size" "731"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2593180"
|
||||
{
|
||||
"dlcappid" "2593180"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "2908546599585986601"
|
||||
"size" "14463"
|
||||
"download" "14528"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2593190"
|
||||
{
|
||||
"dlcappid" "2593190"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "1427647465435055606"
|
||||
"size" "197908084"
|
||||
"download" "180975120"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2593200"
|
||||
{
|
||||
"dlcappid" "2593200"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "6641524981969165665"
|
||||
"size" "5583"
|
||||
"download" "5472"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2593210"
|
||||
{
|
||||
"dlcappid" "2593210"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "971487897150453015"
|
||||
"size" "8506"
|
||||
"download" "8416"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2593220"
|
||||
{
|
||||
"dlcappid" "2593220"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "7751533332300224932"
|
||||
"size" "6072"
|
||||
"download" "5984"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2593230"
|
||||
{
|
||||
"dlcappid" "2593230"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "3205009877271857381"
|
||||
"size" "4433"
|
||||
"download" "4320"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2593240"
|
||||
{
|
||||
"dlcappid" "2593240"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "2644417301132228564"
|
||||
"size" "5471"
|
||||
"download" "5360"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2593250"
|
||||
{
|
||||
"dlcappid" "2593250"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "4059800356804376977"
|
||||
"size" "727"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2593260"
|
||||
{
|
||||
"dlcappid" "2593260"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "1542297718911676562"
|
||||
"size" "5004"
|
||||
"download" "4944"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2593270"
|
||||
{
|
||||
"dlcappid" "2593270"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "2999068646104164918"
|
||||
"size" "5098"
|
||||
"download" "5024"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2593280"
|
||||
{
|
||||
"dlcappid" "2593280"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "3305444330840079072"
|
||||
"size" "693"
|
||||
"download" "752"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2593290"
|
||||
{
|
||||
"dlcappid" "2593290"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "3353726914325255074"
|
||||
"size" "26906257761"
|
||||
"download" "26506078656"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2694820"
|
||||
{
|
||||
"dlcappid" "2694820"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "8323545382908063536"
|
||||
"size" "4994"
|
||||
"download" "4928"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2694830"
|
||||
{
|
||||
"dlcappid" "2694830"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "8243393096252728659"
|
||||
"size" "4980"
|
||||
"download" "4912"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2694840"
|
||||
{
|
||||
"dlcappid" "2694840"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "7407203756785514772"
|
||||
"size" "4980"
|
||||
"download" "4912"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2694850"
|
||||
{
|
||||
"dlcappid" "2694850"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "8989014742962253789"
|
||||
"size" "4992"
|
||||
"download" "4928"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757060"
|
||||
{
|
||||
"dlcappid" "2757060"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "4478133242487678390"
|
||||
"size" "727"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757080"
|
||||
{
|
||||
"dlcappid" "2757080"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "4246061750770449019"
|
||||
"size" "729"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757090"
|
||||
{
|
||||
"dlcappid" "2757090"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "6279084752133001593"
|
||||
"size" "726"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757100"
|
||||
{
|
||||
"dlcappid" "2757100"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "2163371650537928331"
|
||||
"size" "728"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757110"
|
||||
{
|
||||
"dlcappid" "2757110"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "6048538284791812257"
|
||||
"size" "728"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757120"
|
||||
{
|
||||
"dlcappid" "2757120"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "4411956497046953058"
|
||||
"size" "730"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757130"
|
||||
{
|
||||
"dlcappid" "2757130"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "1901727257188878695"
|
||||
"size" "726"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757140"
|
||||
{
|
||||
"dlcappid" "2757140"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "3192862444394827561"
|
||||
"size" "730"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757150"
|
||||
{
|
||||
"dlcappid" "2757150"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "6674463851485663659"
|
||||
"size" "730"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757160"
|
||||
{
|
||||
"dlcappid" "2757160"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "9188163296361277604"
|
||||
"size" "729"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757170"
|
||||
{
|
||||
"dlcappid" "2757170"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "3408012598466658697"
|
||||
"size" "727"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757180"
|
||||
{
|
||||
"dlcappid" "2757180"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "7470516453980211807"
|
||||
"size" "731"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757190"
|
||||
{
|
||||
"dlcappid" "2757190"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "488116235793758299"
|
||||
"size" "726"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757200"
|
||||
{
|
||||
"dlcappid" "2757200"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "3018062872582304551"
|
||||
"size" "729"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"2757210"
|
||||
{
|
||||
"dlcappid" "2757210"
|
||||
"manifests"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"gid" "6248888390153964942"
|
||||
"size" "726"
|
||||
"download" "784"
|
||||
}
|
||||
}
|
||||
}
|
||||
"5184170"
|
||||
{
|
||||
"dlcappid" "5184170"
|
||||
"manifests"
|
||||
{
|
||||
}
|
||||
}
|
||||
"branches"
|
||||
{
|
||||
"public"
|
||||
{
|
||||
"buildid" "25290080"
|
||||
"timeupdated" "1791503987"
|
||||
"timebuildupdated" "1789345188"
|
||||
}
|
||||
}
|
||||
"privatebranches" "1"
|
||||
}
|
||||
"ufs"
|
||||
{
|
||||
"quota" "2000000000"
|
||||
"maxnumfiles" "1000"
|
||||
}
|
||||
}
|
||||
| ||||