mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 11:31:08 +00:00
Added games: the user's own Windows games in the client's library
A Games folder chosen in the Steam cog is scanned one level deep, one subfolder per game; the program to launch is the .exe named after the folder, else the largest, never an installer, redistributable or crash reporter, and the settings page lets the user pick another for any game. Each session the app writes the list, and bannerlator-steam-shortcuts puts the games into the client's shortcuts.vdf as non-Steam games (the client's own entries kept) with the appid the client itself derives, mapped to the ARM64 Proton through the compat registrar. They appear in the rail under Steam and launch by steam://rungameid/ like any title.
This commit is contained in:
1 parent
cc3b963dd0
commit
65b00242d9
9 files changed
+380
-3
No files matched your search
@@ -2,6 +2,7 @@ package com.steamdeck.launcher
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import java.io.File
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
@@ -113,6 +114,26 @@ class MainActivity : ComponentActivity() {
|
||||
private val pickAndroidDriver = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedUri(r.data)?.let { importDriver(it, linux = false) }
|
||||
}
|
||||
private val pickAddedGamesDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
|
||||
SessionPrefs.setAddedGamesDir(this, path)
|
||||
addedGamesDir = path
|
||||
refreshAddedGames()
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
private var pendingAddedGame: String? = null
|
||||
private val pickAddedGameExe = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
val folder = pendingAddedGame ?: return@registerForActivityResult
|
||||
pendingAddedGame = null
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
|
||||
SessionPrefs.setAddedGameExe(this, folder, path)
|
||||
refreshAddedGames()
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
private var addedGamesDir by mutableStateOf("")
|
||||
private var addedGames by mutableStateOf<List<com.steamdeck.launcher.ui.AddedGameRow>>(emptyList())
|
||||
private val pickRomsDir = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
|
||||
if (r.resultCode == RESULT_OK) InAppFilePicker.pickedPath(r.data)?.let { path ->
|
||||
SessionPrefs.setRomsDir(this, path)
|
||||
@@ -183,7 +204,7 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
onSteamGame = { g ->
|
||||
startActivity(Intent(this, SessionActivity::class.java)
|
||||
.putExtra(SessionService.EXTRA_STEAM_URL, "steam://rungameid/${g.appId}"))
|
||||
.putExtra(SessionService.EXTRA_STEAM_URL, "steam://rungameid/${g.gameId}"))
|
||||
},
|
||||
onDesktop = {
|
||||
startActivity(Intent(this, SessionActivity::class.java)
|
||||
@@ -371,6 +392,8 @@ class MainActivity : ComponentActivity() {
|
||||
storageOptions = storageOptions,
|
||||
fexPreset = if (mode == SessionService.MODE_STEAM) fexPreset else null,
|
||||
steamChannel = if (mode == SessionService.MODE_STEAM) steamChannel else null,
|
||||
addedGamesDir = if (mode == SessionService.MODE_STEAM) addedGamesDir else null,
|
||||
addedGames = if (mode == SessionService.MODE_STEAM) addedGames else emptyList(),
|
||||
),
|
||||
ModeSettingsActions(
|
||||
onResolution = { cap -> SessionPrefs.setResolutionCap(this, mode, cap); resolutionCap = cap },
|
||||
@@ -401,6 +424,13 @@ class MainActivity : ComponentActivity() {
|
||||
},
|
||||
onFexPreset = { id -> SessionPrefs.setFexPreset(this, id); fexPreset = id },
|
||||
onSteamChannel = { id -> SessionPrefs.setSteamChannel(this, id); steamChannel = id },
|
||||
onPickAddedGamesDir = { pickAddedGamesDir.launch(InAppFilePicker.buildDirIntent(this, "Choose the folder of your own games", addedGamesDir.ifEmpty { null })) },
|
||||
onClearAddedGamesDir = { SessionPrefs.setAddedGamesDir(this, ""); addedGamesDir = ""; addedGames = emptyList(); refresh() },
|
||||
onAddedGameExe = { folder, path -> SessionPrefs.setAddedGameExe(this, folder, path); refreshAddedGames(); refresh() },
|
||||
onPickAddedGameExe = { folder ->
|
||||
pendingAddedGame = folder
|
||||
pickAddedGameExe.launch(InAppFilePicker.buildIntent(this, listOf("exe"), "Choose the game's .exe", File(addedGamesDir, folder).path))
|
||||
},
|
||||
onDismiss = { settingsMode = null },
|
||||
),
|
||||
)
|
||||
@@ -434,11 +464,20 @@ class MainActivity : ComponentActivity() {
|
||||
)
|
||||
}
|
||||
|
||||
/** The added games as the settings page lists them; a scan of the folder, on this thread (one level, small). */
|
||||
private fun refreshAddedGames() {
|
||||
addedGames = com.steamdeck.launcher.frontend.AddedGames.scan(this).map { g ->
|
||||
com.steamdeck.launcher.ui.AddedGameRow(g.folderName(), g.exe.path, g.exe.name, g.candidates.map { c -> c.path to c.name }.distinctBy { it.first })
|
||||
}
|
||||
}
|
||||
|
||||
private fun openModeSettings(mode: String) {
|
||||
refreshDrivers()
|
||||
resolutionCap = SessionPrefs.resolutionCap(this, mode)
|
||||
fexPreset = SessionPrefs.fexPreset(this)
|
||||
steamChannel = SessionPrefs.steamChannel(this)
|
||||
addedGamesDir = SessionPrefs.addedGamesDir(this)
|
||||
refreshAddedGames()
|
||||
shapeMode = SessionPrefs.shapeMode(this)
|
||||
hdrOn = SessionPrefs.hdr(this, mode)
|
||||
hdrReason = com.steamdeck.launcher.wayland.HdrSupport.probe(this).reason
|
||||
@@ -574,7 +613,9 @@ class MainActivity : ComponentActivity() {
|
||||
} else null
|
||||
// The libraries, off the main thread: manifests and a folder scan.
|
||||
Thread({
|
||||
val games = if (ready) Library.steamGames(this) else emptyList()
|
||||
val games = if (ready) Library.steamGames(this) + com.steamdeck.launcher.frontend.AddedGames.scan(this).map { g ->
|
||||
Library.SteamGame(g.appId.toInt(), g.name, null, "added", g.gameId)
|
||||
} else emptyList()
|
||||
val emus = Library.emulators(this) { id -> DesktopCatalog.installed(this, id) != null }
|
||||
ui.post { steamGames = games; emulatorList = emus }
|
||||
}, "library").start()
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.steamdeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import android.util.Log
|
||||
import com.steamdeck.launcher.session.GameStorage
|
||||
import com.steamdeck.launcher.session.SessionPrefs
|
||||
import java.io.File
|
||||
import java.util.zip.CRC32
|
||||
|
||||
/**
|
||||
* The user's own Windows games, added to the Steam client's library.
|
||||
*
|
||||
* A Games folder is scanned one level deep: each subfolder is one game, and the program to launch
|
||||
* is the .exe in it that looks most like the game - the one named after the folder, else the
|
||||
* largest, never an installer, redistributable or crash reporter - unless the user picked one
|
||||
* for that game. Each game becomes a non-Steam shortcut in the client, under the ARM64 Proton,
|
||||
* with the appid the client itself would derive, so launching from the rail and from the
|
||||
* client's own library are the same thing. The folder has to be somewhere the session can see:
|
||||
* internal storage (/root/Storage), the ROMs folder, or the SD-card game library.
|
||||
*/
|
||||
object AddedGames {
|
||||
private const val TAG = "AddedGames"
|
||||
|
||||
class Game(
|
||||
val folder: File, val name: String, val exe: File, val guestExe: String, val guestDir: String,
|
||||
/** The client's 32-bit appid for this shortcut, as an unsigned value. */
|
||||
val appId: Long,
|
||||
/** What steam://rungameid/ takes for a shortcut. */
|
||||
val gameId: Long,
|
||||
val candidates: List<File>,
|
||||
) {
|
||||
fun folderName(): String = folder.name
|
||||
}
|
||||
|
||||
private val SKIP = Regex(
|
||||
"(?i)^(unins.*|setup.*|.*redist.*|vcredist.*|dxsetup.*|dxwebsetup.*|.*crash.*|.*report.*|dotnet.*|directx.*|.*prereq.*" +
|
||||
"|.*installer.*|.*uninstall.*|.*updater?.*|.*config(ur.*)?|.*settings.*|.*editor.*|.*server.*|.*benchmark.*|.*helper.*|.*eac.*|.*easyanticheat.*|.*battleye.*)\\.exe$",
|
||||
)
|
||||
|
||||
/** Where a host path appears inside the session, or null when the session cannot see it. */
|
||||
fun guestPath(context: Context, host: File): String? {
|
||||
val path = host.absolutePath
|
||||
val storage = Environment.getExternalStorageDirectory().absolutePath
|
||||
if (path.startsWith("$storage/")) return "/root/Storage/" + path.removePrefix("$storage/")
|
||||
SessionPrefs.romsDir(context).takeIf { it.isNotEmpty() }?.let { roms ->
|
||||
if (path.startsWith("$roms/")) return "/root/ROMs/" + path.removePrefix("$roms/")
|
||||
}
|
||||
GameStorage.effective(context)?.let { lib ->
|
||||
if (path.startsWith("${lib.path}/")) return "/mnt/bannerlator-sd/" + path.removePrefix("${lib.path}/")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** The .exe files a game folder offers, best first. */
|
||||
fun candidates(folder: File): List<File> {
|
||||
val exes = ArrayList<File>()
|
||||
val roots = listOf(folder) + (folder.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList())
|
||||
for (dir in roots) {
|
||||
dir.listFiles { f -> f.isFile && f.name.endsWith(".exe", ignoreCase = true) && !SKIP.matches(f.name) }
|
||||
?.let { exes.addAll(it) }
|
||||
}
|
||||
val key = folder.name.lowercase().replace(Regex("[^a-z0-9]"), "")
|
||||
return exes.sortedWith(
|
||||
compareByDescending<File> { it.parentFile == folder }
|
||||
.thenByDescending { it.nameWithoutExtension.lowercase().replace(Regex("[^a-z0-9]"), "").let { n -> n == key || key.startsWith(n) || n.startsWith(key) } }
|
||||
.thenByDescending { it.length() },
|
||||
)
|
||||
}
|
||||
|
||||
fun scan(context: Context): List<Game> {
|
||||
val dir = SessionPrefs.addedGamesDir(context).takeIf { it.isNotEmpty() }?.let { File(it) } ?: return emptyList()
|
||||
if (!dir.isDirectory) return emptyList()
|
||||
val out = ArrayList<Game>()
|
||||
for (folder in dir.listFiles { f -> f.isDirectory }?.sortedBy { it.name.lowercase() } ?: emptyList()) {
|
||||
val candidates = candidates(folder)
|
||||
val chosen = SessionPrefs.addedGameExe(context, folder.name).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
|
||||
val exe = chosen ?: candidates.firstOrNull() ?: continue
|
||||
val guestExe = guestPath(context, exe) ?: run { Log.w(TAG, "${folder.name}: the session cannot see ${exe.path}"); continue }
|
||||
val guestDir = guestPath(context, exe.parentFile ?: folder) ?: continue
|
||||
val name = folder.name
|
||||
val crc = CRC32().apply { update(("\"$guestExe\"" + name).toByteArray()) }.value
|
||||
val appId = crc or 0x80000000L
|
||||
out.add(Game(folder, name, exe, guestExe, guestDir, appId, (appId shl 32) or 0x02000000L, candidates))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/** The list the session hands the runtime's shortcuts writer; one file per session start. */
|
||||
fun writeListing(context: Context, games: List<Game>): File {
|
||||
val file = File(context.filesDir, "session/added-games.json").apply { parentFile?.mkdirs() }
|
||||
val json = StringBuilder("[")
|
||||
games.forEachIndexed { i, g ->
|
||||
if (i > 0) json.append(',')
|
||||
json.append("{\"name\":").append(quote(g.name)).append(",\"exe\":").append(quote(g.guestExe))
|
||||
.append(",\"dir\":").append(quote(g.guestDir)).append(",\"appid\":").append(g.appId).append('}')
|
||||
}
|
||||
file.writeText(json.append(']').toString())
|
||||
return file
|
||||
}
|
||||
|
||||
private fun quote(s: String) = "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\""
|
||||
}
|
||||
@@ -13,7 +13,8 @@ import java.io.File
|
||||
* folder as a hint). Read on a worker thread; nothing here is cached beyond one screen refresh.
|
||||
*/
|
||||
object Library {
|
||||
class SteamGame(val appId: Int, val name: String, val art: File?, val library: String)
|
||||
/** [gameId] is what steam://rungameid/ takes: the appid for a Steam title, the shortcut id for an added game. */
|
||||
class SteamGame(val appId: Int, val name: String, val art: File?, val library: String, val gameId: Long = appId.toLong())
|
||||
class Rom(val name: String, val hostPath: File, val guestPath: String, val emulatorId: String, val art: File? = null)
|
||||
class Emulator(val id: String, val name: String, val system: String, val program: String, val installed: Boolean, val games: List<Rom>) {
|
||||
/** The emulator's own icon, bundled (the runtime keeps them as theme SVGs the app cannot draw). */
|
||||
|
||||
@@ -37,6 +37,7 @@ object SessionFiles {
|
||||
"usr/local/bin/bannerlator-steam-launch" to "usr/local/bin/bannerlator-steam-launch",
|
||||
"usr/local/bin/bannerlator-desktop-games" to "usr/local/bin/bannerlator-desktop-games",
|
||||
"usr/local/bin/bannerlator-steam-shim" to "usr/local/bin/bannerlator-steam-shim",
|
||||
"usr/local/bin/bannerlator-steam-shortcuts" to "usr/local/bin/bannerlator-steam-shortcuts",
|
||||
// The SteamOS helpers the client calls in Deck mode: the two Armada found it needs, plus
|
||||
// the three under /usr/bin, all no-ops that answer "nothing to do" (see each file).
|
||||
"usr/bin/steamos-update" to "usr/bin/steamos-update",
|
||||
|
||||
@@ -233,6 +233,20 @@ object SessionPrefs {
|
||||
prefs(context).edit().putString("steamChannel", id).apply()
|
||||
}
|
||||
|
||||
/** The folder of the user's own Windows games (one subfolder each), "" = none. */
|
||||
fun addedGamesDir(context: Context): String = prefs(context).getString("addedGamesDir", "") ?: ""
|
||||
|
||||
fun setAddedGamesDir(context: Context, path: String) {
|
||||
prefs(context).edit().putString("addedGamesDir", path).apply()
|
||||
}
|
||||
|
||||
/** The .exe the user chose for one game folder (by folder name), "" = the scanner's pick. */
|
||||
fun addedGameExe(context: Context, folderName: String): String = prefs(context).getString("addedExe:$folderName", "") ?: ""
|
||||
|
||||
fun setAddedGameExe(context: Context, folderName: String, path: String) {
|
||||
prefs(context).edit().putString("addedExe:$folderName", path).apply()
|
||||
}
|
||||
|
||||
/** The app's colour theme (ui/Themes ids); Paper on black unless chosen otherwise. */
|
||||
fun theme(context: Context): String = prefs(context).getString("theme", "paper") ?: "paper"
|
||||
|
||||
|
||||
@@ -316,6 +316,14 @@ class SessionService : Service() {
|
||||
.takeIf { it.isFile }?.let { FileUtils.readString(it)?.trim() }
|
||||
guest.add("BL_WLR_RENDERER=" + (override?.takeIf { it.isNotEmpty() } ?: SessionPrefs.desktopRenderer(this)))
|
||||
}
|
||||
// The user's own games, for the runtime's shortcuts writer to put in the client's library
|
||||
// before the client starts (see frontend/AddedGames and bannerlator-steam-shortcuts).
|
||||
if (SessionState.mode == MODE_STEAM) {
|
||||
val added = com.steamdeck.launcher.frontend.AddedGames.scan(this)
|
||||
val listing = com.steamdeck.launcher.frontend.AddedGames.writeListing(this, added)
|
||||
guest.add("BL_ADDED_GAMES=" + listing.path)
|
||||
if (added.isNotEmpty()) Log.i(TAG, "added games: " + added.joinToString { "${it.name} (${it.exe.name})" })
|
||||
}
|
||||
// Where the guest leaves a request for another session (the desktop's Steam launchers).
|
||||
guest.add("BL_LAUNCH_DIR=" + sessionRoot.path)
|
||||
// The second library's name, for bannerlator-steam-library; the bind itself is made below.
|
||||
|
||||
@@ -37,8 +37,14 @@ class ModeSettings(
|
||||
val fexPreset: String? = null,
|
||||
/** Steam only: the client branch forced on the command line. */
|
||||
val steamChannel: String? = null,
|
||||
/** Steam only: the user's own games folder and what was found in it. */
|
||||
val addedGamesDir: String? = null,
|
||||
val addedGames: List<AddedGameRow> = emptyList(),
|
||||
)
|
||||
|
||||
/** One added game as the settings page shows it: its folder, the chosen .exe, the other .exe files it could be. */
|
||||
class AddedGameRow(val folderName: String, val exePath: String, val exeName: String, val candidates: List<Pair<String, String>>)
|
||||
|
||||
class ModeSettingsActions(
|
||||
val onResolution: (Int) -> Unit,
|
||||
val onShape: (String) -> Unit,
|
||||
@@ -58,6 +64,10 @@ class ModeSettingsActions(
|
||||
val onPickGameStorageFolder: () -> Unit = {},
|
||||
val onFexPreset: (String) -> Unit = {},
|
||||
val onSteamChannel: (String) -> Unit = {},
|
||||
val onPickAddedGamesDir: () -> Unit = {},
|
||||
val onClearAddedGamesDir: () -> Unit = {},
|
||||
val onAddedGameExe: (folderName: String, path: String) -> Unit = { _, _ -> },
|
||||
val onPickAddedGameExe: (folderName: String) -> Unit = {},
|
||||
val onDismiss: () -> Unit,
|
||||
)
|
||||
|
||||
@@ -135,6 +145,22 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
|
||||
onPick = a.onSteamChannel,
|
||||
)
|
||||
}
|
||||
if (steam && s.addedGamesDir != null) SettingsGroup("Added games") {
|
||||
ActionRow(
|
||||
"Games folder",
|
||||
if (s.addedGamesDir.isEmpty()) "Your own Windows games, one subfolder each, anywhere on internal storage, in the ROMs folder or on the SD-card library. Each goes into the client's library as a non-Steam game under the ARM64 Proton, at the next session start."
|
||||
else s.addedGamesDir + " · " + (if (s.addedGames.isEmpty()) "no game folders with a .exe found" else "${s.addedGames.size} game${if (s.addedGames.size == 1) "" else "s"}"),
|
||||
if (s.addedGamesDir.isEmpty()) "Choose…" else "Change…",
|
||||
onClick = a.onPickAddedGamesDir,
|
||||
)
|
||||
if (s.addedGamesDir.isNotEmpty()) ActionRow("Forget the folder", "The games leave the client's library at the next session start; nothing on disk is touched.", "Forget", onClick = a.onClearAddedGamesDir)
|
||||
for (g in s.addedGames) ChoiceRow(
|
||||
host, "added:" + g.folderName, g.folderName, "Launches ${g.exeName}",
|
||||
g.candidates + ("__pick__" to "Choose another file…"), g.exePath,
|
||||
note = "The .exe files found in the game's folder; the one named after the folder, else the largest, is picked unless you choose.",
|
||||
onPick = { path -> if (path == "__pick__") a.onPickAddedGameExe(g.folderName) else a.onAddedGameExe(g.folderName, path) },
|
||||
)
|
||||
}
|
||||
if (steam && s.fexPreset != null) SettingsGroup("Games") {
|
||||
ChoiceRow(
|
||||
host, "fex", "FEX preset", "How FEX translates the x86 games the client launches. Applies to the next game launch. Also in the session's drawer.",
|
||||
|
||||
@@ -216,6 +216,12 @@ case $mode in
|
||||
bannerlator-steam-library "$steam_root" || true
|
||||
step "setting up the ARM64 Proton compatibility tool"
|
||||
bannerlator-steam-compat "$steam_root" || true
|
||||
# The user's own games, as non-Steam shortcuts under that tool; the list is written by the app
|
||||
# each session (empty = the app's earlier entries are removed, the client's own are kept).
|
||||
if [ -n "${BL_ADDED_GAMES:-}" ]; then
|
||||
step "adding the app's own games to the client's library"
|
||||
bannerlator-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true
|
||||
fi
|
||||
# Steam runs a game's shared redistributables inside its prefix on first launch; marking
|
||||
# them as already run keeps those x86 installers from starting under emulation at all.
|
||||
bannerlator-seed-redists "$steam_root" || true
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Puts the app's added games (Windows games in the user's own folders) into the Steam client's
|
||||
library as non-Steam shortcuts, each mapped to the ARM64 Proton, before the client starts.
|
||||
|
||||
bannerlator-steam-shortcuts <steam_root> <added-games.json>
|
||||
|
||||
The JSON is a list the app writes each session: {"name", "exe", "dir", "appid"} with guest paths.
|
||||
The client keeps non-Steam games in userdata/<account>/config/shortcuts.vdf, a binary VDF it
|
||||
reads at start-up and rewrites at exit, so this runs while it is down. Entries the app wrote
|
||||
carry the tag "steamdeck-app" and are replaced wholesale; anything the user added inside the
|
||||
client is kept as it is. The appid is the one the client itself derives (crc32 of exe+name with
|
||||
the top bit set), so the launch URL the app uses matches what the client shows.
|
||||
"""
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import sys
|
||||
|
||||
TAG = "steamdeck-app"
|
||||
|
||||
|
||||
def account_dir(steam_root):
|
||||
"""The most recent account's userdata folder, else the only one that is not 0."""
|
||||
users = os.path.join(steam_root, "config", "loginusers.vdf")
|
||||
recent = None
|
||||
try:
|
||||
with open(users, encoding="utf-8", errors="replace") as f:
|
||||
text = f.read()
|
||||
for block in re.finditer(r'"(\d{5,})"\s*\{([^}]*)\}', text):
|
||||
if re.search(r'"MostRecent"\s*"1"', block.group(2)):
|
||||
recent = int(block.group(1))
|
||||
except OSError:
|
||||
pass
|
||||
userdata = os.path.join(steam_root, "userdata")
|
||||
if recent is not None:
|
||||
# loginusers holds the 64-bit id; userdata folders use the 32-bit account id.
|
||||
acct = recent - 76561197960265728
|
||||
return os.path.join(userdata, str(acct))
|
||||
try:
|
||||
dirs = [d for d in os.listdir(userdata) if d.isdigit() and d != "0"]
|
||||
except OSError:
|
||||
dirs = []
|
||||
return os.path.join(userdata, dirs[0]) if len(dirs) == 1 else None
|
||||
|
||||
|
||||
# ---- binary VDF: 0x00 map, 0x01 string, 0x02 int32, 0x08 end ----
|
||||
|
||||
def parse(data):
|
||||
pos = [0]
|
||||
|
||||
def cstr():
|
||||
end = data.index(b"\x00", pos[0])
|
||||
s = data[pos[0]:end].decode("utf-8", "replace")
|
||||
pos[0] = end + 1
|
||||
return s
|
||||
|
||||
def node():
|
||||
out = {}
|
||||
while True:
|
||||
t = data[pos[0]]
|
||||
pos[0] += 1
|
||||
if t == 0x08:
|
||||
return out
|
||||
key = cstr()
|
||||
if t == 0x00:
|
||||
out[key] = node()
|
||||
elif t == 0x01:
|
||||
out[key] = cstr()
|
||||
elif t == 0x02:
|
||||
out[key] = struct.unpack_from("<i", data, pos[0])[0]
|
||||
pos[0] += 4
|
||||
else:
|
||||
raise ValueError("unknown vdf type 0x%02x" % t)
|
||||
|
||||
t = data[pos[0]]
|
||||
pos[0] += 1
|
||||
key = cstr()
|
||||
return {key: node()}
|
||||
|
||||
|
||||
def write(obj):
|
||||
out = bytearray()
|
||||
|
||||
def emit(key, value):
|
||||
if isinstance(value, dict):
|
||||
out.append(0x00)
|
||||
out += key.encode() + b"\x00"
|
||||
for k, v in value.items():
|
||||
emit(k, v)
|
||||
out.append(0x08)
|
||||
elif isinstance(value, int):
|
||||
out.append(0x02)
|
||||
out += key.encode() + b"\x00" + struct.pack("<i", value)
|
||||
else:
|
||||
out.append(0x01)
|
||||
out += key.encode() + b"\x00" + str(value).encode() + b"\x00"
|
||||
|
||||
for k, v in obj.items():
|
||||
emit(k, v)
|
||||
out.append(0x08)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def shortcut(game):
|
||||
appid = int(game["appid"]) & 0xFFFFFFFF
|
||||
return {
|
||||
"appid": struct.unpack("<i", struct.pack("<I", appid))[0],
|
||||
"AppName": game["name"],
|
||||
"Exe": '"%s"' % game["exe"],
|
||||
"StartDir": '"%s"' % game["dir"],
|
||||
"icon": "",
|
||||
"ShortcutPath": "",
|
||||
"LaunchOptions": "",
|
||||
"IsHidden": 0,
|
||||
"AllowDesktopConfig": 1,
|
||||
"AllowOverlay": 1,
|
||||
"OpenVR": 0,
|
||||
"Devkit": 0,
|
||||
"DevkitGameID": "",
|
||||
"DevkitOverrideAppID": 0,
|
||||
"LastPlayTime": 0,
|
||||
"FlatpakAppID": "",
|
||||
"tags": {"0": TAG},
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
print(__doc__)
|
||||
return 2
|
||||
steam_root, listing = sys.argv[1], sys.argv[2]
|
||||
try:
|
||||
with open(listing, encoding="utf-8") as f:
|
||||
games = json.load(f)
|
||||
except (OSError, ValueError) as e:
|
||||
print("bannerlator-steam-shortcuts: no list (%s)" % e)
|
||||
return 0
|
||||
acct = account_dir(steam_root)
|
||||
if not acct:
|
||||
print("bannerlator-steam-shortcuts: no signed-in account yet; nothing written")
|
||||
return 0
|
||||
config = os.path.join(acct, "config")
|
||||
os.makedirs(config, exist_ok=True)
|
||||
path = os.path.join(config, "shortcuts.vdf")
|
||||
existing = {}
|
||||
if os.path.isfile(path):
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
existing = parse(f.read()).get("shortcuts", {})
|
||||
except (ValueError, IndexError) as e:
|
||||
print("bannerlator-steam-shortcuts: could not read the existing file (%s); starting over" % e)
|
||||
kept = [v for v in existing.values() if isinstance(v, dict) and TAG not in (v.get("tags") or {}).values()]
|
||||
ours = [shortcut(g) for g in games if g.get("exe") and g.get("name")]
|
||||
entries = {str(i): v for i, v in enumerate(kept + ours)}
|
||||
tmp = path + ".bannerlator"
|
||||
with open(tmp, "wb") as f:
|
||||
f.write(write({"shortcuts": entries}))
|
||||
os.replace(tmp, path)
|
||||
print("bannerlator-steam-shortcuts: %d added game(s) written, %d of the client's own kept" % (len(ours), len(kept)))
|
||||
# Each added game runs under the ARM64 Proton: the compat registrar's own mapping writer.
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location("compat", "/usr/local/bin/bannerlator-steam-compat")
|
||||
compat = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(compat)
|
||||
cfg = os.path.join(steam_root, "config", "config.vdf")
|
||||
if os.path.isfile(cfg) and ours:
|
||||
compat.register_default(cfg, apps=[str(int(g["appid"]) & 0xFFFFFFFF) for g in games if g.get("exe")])
|
||||
print("bannerlator-steam-shortcuts: mapped them to %s" % compat.TOOL)
|
||||
except Exception as e: # noqa: BLE001 - the games still show; the client falls back to the default tool
|
||||
print("bannerlator-steam-shortcuts: compat mapping skipped (%s)" % e)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in new issue
Block a user