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:
The412Banner committed 2026-09-23 09:12:15 -04:00
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())