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:
The412Banner authored and GitHub committed 2026-10-10 21:15:56 -04:00
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)
+20 -2
View File
@@ -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>
+18 -2
View File
@@ -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>
+18 -2
View File
@@ -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>
+18 -2
View File
@@ -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>
+18 -2
View File
@@ -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>
+18 -2
View File
@@ -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>
+19 -2
View File
@@ -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"
}
}
@@ -0,0 +1,116 @@
# Windows components by source
A game's Windows components (VC++ runtimes, DirectX's d3dx9, XNA, PhysX...) are picked for it by
itself, from the list its source keeps. The source badge (Steam, GOG, Epic Games, Amazon Games,
Custom) decides whose list is trusted (`WinCompSources`):
| Source | List | On by itself |
| --- | --- | --- |
| Steam | The Steamworks Shared redistributables Steam installs with the game: the appmanifest's `SharedDepots`, else the client's app info (`SteamAppInfo`) | yes |
| GOG | The build manifest's `dependencies`, kept in the sidecar at install (`extra.dependencies`) | yes |
| Epic Games | The manifest's prerequisite (`PrereqName`, `PrereqPath`), kept in the sidecar at install | yes, for the mapped parts |
| Amazon Games | fuel.json's `PostInstall` installers | yes, for the mapped parts |
| Custom, appid in its files | Steam's list for that app (`SteamMatch` certainty `FILES`: steam_appid.txt, also under steam_settings/ or a parent; an emulator ini; an appmanifest) | yes |
| Custom, matched by name | Steam's list for that app (certainty `NAME`, a store search with the same name) | no, Recommended |
| Custom, no match | the folder scan (`DependencyDetector`) | no, Recommended |
The folder scan is offered as Recommended for every game.
## Steam's data
Never scraped from a website. `SteamAppInfo` reads the Steam client's own app-info cache in the
runtime (`Steam/appcache/appinfo.vdf`, versions 27-29): the app's depots that point at app 228980
(Steamworks Shared) and its name. Each appid's answer is kept in `files/steam-appinfo.json`, so it
works offline; an app the client has not seen is looked for again after a week. A Steam game with
its appmanifest beside it uses the manifest first. `droiddeck-seed-redists` still marks Steam's
install scripts for those redistributables as already run, so the client never runs the real
installers.
## One Steam match per game
`SteamMatch` keeps, per added game (by its folder), `{appId, certainty FILES|NAME|NONE, source}`
in `files/steam-matches.json`. The art fetch (`AddedGameArt`) and the components read and write
the same record; an earlier build's art lookup (`added-art/<appid>/steam-appid`) is taken over as
a name match. A miss is tried again after a week.
## Mapping
All in `WinCompSources`:
- GOG ids: `MSVC<year>[_x64]` → `vcredist<year>` (2017 → the 2015 family, as the folder scan),
`DirectX*`/`DX9`/`D3DX*` → `d3dx9`, `DOTNET<n>` → `dotnet35`...`dotnet48`, `PhysX` → `physx`,
`OpenAL` → `oalinst`, `XNA3`/`XNA31` → `xna31`, other `XNA*` → `xna40`, `XLive*`/`GFWL*` →
`XLiveRedist`, `XACT*` → `xact`.
- Epic: Unreal's `UE4PrereqSetup`/`UEPrereqSetup` (or a "UE4/UE5/Unreal Engine Prerequisites" name)
→ `vcredist2022` + `d3dx9`; any other installer by its path, as the folder scan names it.
- Amazon: each `PostInstall` command by its path, as the folder scan names it (Unreal's
prerequisite as above).
- Steam: Steamworks Shared depots 228981-228990 (`SteamRedists.DEPOTS`).
Anything that maps to nothing is listed under "Also listed" with its own name. A mapped component
that needs a Windows installer here, or is not in the catalog, stays Recommended with its status.
## On by itself, and the user's switches
All of it runs on one background thread (`AutoComponents`), low priority (the runtime helpers an
install starts run under `nice -n 19` and `ionice -c3`), one job at a time from one queue: a game's
list (worked out again only when the files it is read from change, by modification time, and kept
in `files/wincomp-findings.json`, so a page opens on it at once) and a component's download (each
component once, for every game that wants it). What the user asked for goes first: the page of a
game (on a metered network too), a store install or an add. A game's list is queued when it is
installed, added or edited, when its page opens, when it is launched from DroidDeck (the launch
itself only takes what is on already), and for every Steam game once per app start, ten seconds
after the launcher is up (Steam installs them without a word to DroidDeck). The queue waits while
a session runs, and a download also waits for a validated network (the page then greys what is not
downloaded yet, "Available when online", and follows the network live); what nobody asked for also waits on battery saver, thermal status moderate or
worse, or a metered network. A component turns on for its games when its download lands, for the
next launch, and an open page shows the progress and reads the picks again. One log line per
queued and per finished download.
`wincomponents.json` keeps, per game,
`auto` (each with its reason) and `user` (each switch the user pressed, on or off) beside `games`,
the effective list the launch reads (still version 1). A user switch always wins over an automatic
pick, either way, and a new automatic list never touches it. Picks saved before this build count
as the user's own.
The page shows "On for this game" with each reason ("From GOG's list for this game", "Steam
installs VC++ 2015 with <game> (app N)"), then Recommended, then "Also listed".
## Older store installs
A GOG or Epic game installed before its sidecar kept the store's list gets it once, on the worker
(`StoreListBackfill`): GOG's build manifest for the installed build (`dependencies`), Epic's
manifest (prerequisite name and path), fetched with the saved sign-in - the manifest alone, never a
game file - and kept in the sidecar as an install keeps it. Fresh installs now keep the keys even
when empty. Without a sign-in or when the store refuses: skipped quietly, tried again a week later;
offline is no try. Like any work nobody asked for it waits for an unmetered network, unless the
game's page is open.
## Apps the client never saw
A Steam app the client in the runtime never saw (not owned, never viewed: a Custom game's
steam_appid.txt) is missing from its `appinfo.vdf`. `SteamPics` asks Steam for it anonymously:
Steam's public CM list (`ISteamDirectory/GetCMListForConnect`), a WebSocket connection to a
connection manager (`wss://<cm>/cmsocket/`, the transport Steam's web clients use), an anonymous
logon (an AnonUser SteamID, no account, nothing of the user's Steam sign-in), a
`ClientPICSAccessTokenRequest` and a `ClientPICSProductInfoRequest` (at most 25 apps each, all in
one connection), the app KeyValues read for depots with `depotfromapp` 228980 and the name, and a
logoff. Kotlin only, no library (TLS from the platform, a minimal WebSocket and protobuf reader).
Answers go into `steam-appinfo.json` like the client's (a miss is asked again after a week).
Order for an appid: the client's `appinfo.vdf` first (free), then the anonymous lookup. It runs on
the components' worker: one batch for every game in the start/after-session pass (before their
lists), or the one app of a game whose page is open; never during a session, and as work nobody
asked for only on an unmetered, favourable device. Log: `steam pics: N apps, M with shared redists`.
## A game installed during a session
`SessionInstalls` looks at the Steam libraries' appmanifests every five seconds while a session
runs (a manifest re-read only when its time changes); a game whose StateFlags turn to 4 (fully
installed) becomes one job allowed to run in the session (`AutoComponents.queueInSession`): its
list (local appmanifest, appinfo.vdf, the kept PICS answer; one small anonymous lookup only for an
app nothing knows), its automatic picks written to `wincomponents.json` (the runtime's copy too) and
only its missing components downloaded, at low priority. It waits while a game runs in the session
(Steam's reaper `SteamLaunch AppId=`, or Proton's `waitforexitandrun`). All other work stays
paused. While its components download, the app lists them per game in the runtime's
`~/.config/droiddeck/wincomponents-pending.json`; `droiddeck-wincomponents` at launch waits up to
10 s for those (no wait when nothing is pending), then applies what is there: log
`wincomp: <appid> waited Nms for <ids>` or `... launched without <ids> (not ready)`.
+61
View File
@@ -7,6 +7,67 @@ the timeline, then lessons and backlog. Companion to the README (what the app *d
---
## 2026-10-10 - `feat/wincomp-auto-sources`: Windows components picked by the game's source
- **Per source** (`WinCompSources`): Steam - Steamworks Shared depots from the appmanifest or the
client's app info (`SteamAppInfo`, appinfo.vdf v27-29, cached per appid, weekly retry); GOG - the
build's `dependencies` (now kept in the sidecar); Epic - the manifest's prerequisite (now parsed
and kept); Amazon - fuel.json `PostInstall`; Custom - Steam's list when its files name the appid
(on by itself), when only the name matches (Recommended); else the folder scan. Unmapped entries
are listed with their own names; mapping tables in one place.
- **One Steam match per game** (`SteamMatch`, `files/steam-matches.json`), used by the art fetch
and the components alike; the earlier art lookup is taken over.
- **On by itself** (`AutoComponents`): at store install, add/edit, page open and before a launch
from DroidDeck; `wincomponents.json` keeps `auto` (with reasons) and `user` switches beside the
effective `games` list (still version 1). A user switch always wins. The page gets "On for this
game" with reasons and "Also listed".
- **Device fix:** on a Steam game (Need for Speed Payback) only the already-downloaded d3dx9 went
on; vcredist2013/2019 stayed Recommended, never downloaded (Steam games have no install event
here). Every refresh (page open, launch, an app-start sweep over Steam games, install/add/edit)
now queues the missing automatic picks for a background download and turns each on when it
lands; the open page shows the progress and reloads.
- **Gentle on the device:** one low-priority worker and one queue (games and components each once,
what the user asked for first); nothing while a session runs; the start-up sweep and pre-launch
looks only queue, wait ten seconds after start, and skip battery saver, a warm device or a
metered network; runtime helpers under nice 19 / ionice idle; a game's list kept and worked out
again only when its files change, on the worker. Tests: download lands, queue paused while a
session runs, unfavourable device, dedupe.
- **Fluid:** after a session the Games tab reads Steam's manifests first (no wait for the added
games' walk), a newly installed game eases into the list and its components' list is queued on
the worker. The components page shows "Checking what this game needs…" (small spinner) until
the list is worked out, never "nothing found" before; "On for this game" expands in, components
on their way sit there with their progress and a "Turning on N components…" line, and rows ease
in (~250 ms; instant with animations off).
- **Quieter:** "Checking…" only when the game's list was never worked out; a kept list shows at
once and is checked again silently, its changes easing in. The lists wait for the catalog and
fade in once; "Loading the components…" only after 300 ms.
- **Offline:** with no validated network, components not downloaded yet are greyed and cannot be
switched ("Available when online"), automatic picks on their way included (no progress bar);
downloaded ones stay usable. The page follows the network live, and downloads wait in the queue
until it is back.
- **Every source, older installs:** the start/after-session pass covers all games (Custom, GOG,
Epic, Amazon). GOG/Epic games installed before the sidecar kept the list get it once from the
store's build manifest with the saved sign-in (`StoreListBackfill`; no game files; weekly retry
on failure). Fresh installs keep the keys even when empty.
- **Apps the client never saw** (Dragon's Dogma 2, 2054970 not in appinfo.vdf): the client ask
gave nothing (no `SteamClient` call returns depots), so Steam is asked anonymously instead
(`SteamPics`: CM list, WebSocket CM, anonymous logon, PICS access tokens + product info, ≤25
apps a request, one connection a pass, on the worker; Kotlin, no new library). Prototyped first
against Steam from the build host: 2054970 → depot 228988 (VC++ 2019), 1190460 → one shared
depot. Cached in steam-appinfo.json like the client's, weekly retry on a miss.
- **Installed during a session:** the Steam libraries' appmanifests are looked at every 5 s while
a session runs; a game turning fully installed gets one in-session job (waits while a game runs)
that writes its automatic picks and downloads only its missing components; the launch waits up
to 10 s for components listed in `wincomponents-pending.json`, never when nothing is pending.
- **Checks:** a store list counts as read only when the manifest was read whole (`listRead`;
GOG: a manifest with depots, Epic: the prerequisite fields read); older empty lists are fetched
once more. Log: `backfill: <game> gog deps=[...]` / `epic prereq=<name|none>`, plus the
manifest's depot/file count. Unity's `*_Data/Managed` is skipped by the folder scan (Mono
assemblies, not .NET Framework).
- Docs: `docs/development/windows-components-sources.md`. Tests: `WinCompSourcesTest` (each
source's fixture, certainty rules, appinfo reading, auto picks, user overrides, shared match),
`AddedGameArtLookupTest` updated. Not yet on a device.
## 2026-10-10 - `fix/games-folder-migration`: Games folders become entries, not Steam libraries
- **Seen on a device:** Steam's Install dialog listed "Games (Internal shared storage)" and "Games
@@ -32,6 +32,10 @@ from pathlib import Path
STORE = Path("/opt/droiddeck/wincomponents")
SELECTION = Path.home() / ".config/droiddeck/wincomponents.json"
# Components the app is still downloading for a game Steam installed during this session:
# {"games": {"<appid>": ["vcredist2019_dll", ...]}}. A launch waits a little for them.
PENDING = Path.home() / ".config/droiddeck/wincomponents-pending.json"
PENDING_WAIT = 10.0
# What this prefix holds from us, so a component the user turns off is taken out again.
STATE = ".droiddeck-wincomponents.json"
# A file ours replaced that was not Proton's (a real installer's, the user's own), put back when the
@@ -518,15 +522,47 @@ def save_state(compat, state):
os.replace(temp, compat / STATE)
def apply(compat_path, app_id, selection=SELECTION, store=STORE):
def pending_for(app_id, pending=PENDING):
"""The component ids the app is still getting ready for app_id; empty when none (or no file)."""
try:
with open(pending, encoding="utf-8") as source:
ids = json.load(source).get("games", {}).get(str(app_id), [])
except (OSError, ValueError, AttributeError):
return []
return [i for i in ids if valid_id(i)] if isinstance(ids, list) else []
def wait_for_pending(app_id, pending=PENDING, store=STORE, timeout=PENDING_WAIT, sleep=time.sleep, clock=time.monotonic):
"""Waits (at most timeout seconds) for the components still being got ready for app_id to be in
the store or dropped from the pending list. No wait at all when nothing is pending. Returns a
note for the log, or None."""
def missing():
return [cid for cid in pending_for(app_id, pending) if component(store, cid) is None]
left = missing()
if not left:
return None
asked = list(left)
start = clock()
while left and clock() - start < timeout:
sleep(0.5)
left = missing()
waited = int((clock() - start) * 1000)
if left:
return "wincomp: %s launched without %s (not ready)" % (app_id, ",".join(left))
return "wincomp: %s waited %dms for %s" % (app_id, waited, ",".join(asked))
def apply(compat_path, app_id, selection=SELECTION, store=STORE, pending=PENDING, wait=wait_for_pending):
"""Brings the prefix in line with the game's picks. Returns (overrides, notes)."""
compat = Path(compat_path)
pfx = compat / "pfx"
# A game Steam installed during this session may have components still on their way.
waited = wait(app_id, pending, store) if wait else None
ids = wanted(app_id, selection)
state = load_state(compat)
if not ids and not state["files"] and not state.get("registry"):
return [], []
notes, overrides, keep = [], [], {}
notes, overrides, keep = ([waited] if waited else []), [], {}
copied = 0
installed = []
values = []
+54 -1
View File
@@ -137,7 +137,7 @@ class WinComponentsTest(unittest.TestCase):
self.pick("openal")
globals_ = GAME_ENV["win_components"].__globals__
real = runpy.run_path(str(BIN / "droiddeck-wincomponents"))
real["apply"].__defaults__ = (self.selection, self.store)
real["apply"].__defaults__ = (self.selection, self.store, self.selection.parent / "no-pending.json", None)
with mock.patch.dict(globals_, {"runpy": mock.Mock(run_path=lambda _path: real)}):
self.assertEqual(GAME_ENV["win_components"](str(self.compat) + "/", "2571976725"), "openal32=n,b")
self.assertEqual(GAME_ENV["win_components"]("", "2571976725"), "")
@@ -166,5 +166,58 @@ class WinComponentsTest(unittest.TestCase):
self.assertEqual(MODULE["overrides_value"](["a", "b=n", "a=b", "bad;name", "c=x"]), "b=n;a=b")
class PendingWaitTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
root = Path(self.tmp.name)
self.store = root / "store"
self.pending = root / "pending.json"
self.slept = []
def tearDown(self):
self.tmp.cleanup()
def install(self, cid):
folder = self.store / cid
folder.mkdir(parents=True, exist_ok=True)
(folder / "component.json").write_text(json.dumps({"id": cid, "overrides": []}))
def clock(self):
return len(self.slept) * 0.5
def wait(self, timeout=10.0, on_sleep=None):
def sleep(seconds):
self.slept.append(seconds)
if on_sleep:
on_sleep(len(self.slept))
return MODULE["wait_for_pending"]("1262580", self.pending, self.store, timeout, sleep, self.clock)
def test_nothing_pending_means_no_wait(self):
self.assertIsNone(self.wait())
self.pending.write_text(json.dumps({"games": {"999": ["d3dx9"]}}))
self.assertIsNone(self.wait())
self.install("d3dx9")
self.pending.write_text(json.dumps({"games": {"1262580": ["d3dx9"]}}))
self.assertIsNone(self.wait())
self.assertEqual([], self.slept)
def test_waits_until_the_component_lands(self):
self.pending.write_text(json.dumps({"games": {"1262580": ["vcredist2019_dll"]}}))
note = self.wait(on_sleep=lambda n: self.install("vcredist2019_dll") if n == 3 else None)
self.assertEqual(3, len(self.slept))
self.assertEqual("wincomp: 1262580 waited 1500ms for vcredist2019_dll", note)
def test_gives_up_cleanly_after_the_timeout(self):
self.pending.write_text(json.dumps({"games": {"1262580": ["vcredist2019_dll", "d3dx9"]}}))
self.install("d3dx9")
note = self.wait(timeout=2.0)
self.assertEqual(4, len(self.slept))
self.assertEqual("wincomp: 1262580 launched without vcredist2019_dll (not ready)", note)
def test_a_damaged_pending_file_is_no_wait(self):
self.pending.write_text("{not json")
self.assertIsNone(self.wait())
if __name__ == "__main__":
unittest.main()