diff --git a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt index a8986e50e..0eace7cca 100644 --- a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt @@ -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>(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() diff --git a/app/src/main/java/com/steamdeck/launcher/frontend/AddedGames.kt b/app/src/main/java/com/steamdeck/launcher/frontend/AddedGames.kt new file mode 100644 index 000000000..ee31d50a6 --- /dev/null +++ b/app/src/main/java/com/steamdeck/launcher/frontend/AddedGames.kt @@ -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, + ) { + 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 { + val exes = ArrayList() + 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 { 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 { + val dir = SessionPrefs.addedGamesDir(context).takeIf { it.isNotEmpty() }?.let { File(it) } ?: return emptyList() + if (!dir.isDirectory) return emptyList() + val out = ArrayList() + 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): 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("\"", "\\\"") + "\"" +} diff --git a/app/src/main/java/com/steamdeck/launcher/frontend/Library.kt b/app/src/main/java/com/steamdeck/launcher/frontend/Library.kt index b77246828..4b491242d 100644 --- a/app/src/main/java/com/steamdeck/launcher/frontend/Library.kt +++ b/app/src/main/java/com/steamdeck/launcher/frontend/Library.kt @@ -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) { /** The emulator's own icon, bundled (the runtime keeps them as theme SVGs the app cannot draw). */ diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionFiles.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionFiles.kt index f619c9d6d..66e05168f 100644 --- a/app/src/main/java/com/steamdeck/launcher/session/SessionFiles.kt +++ b/app/src/main/java/com/steamdeck/launcher/session/SessionFiles.kt @@ -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", diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt index 4632af37f..29eb761ef 100644 --- a/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt @@ -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" diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt index 5dc246c11..9e054bb98 100644 --- a/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt +++ b/app/src/main/java/com/steamdeck/launcher/session/SessionService.kt @@ -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. diff --git a/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt index b70aa92c4..eabe1c7e1 100644 --- a/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt @@ -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 = 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>) + 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.", diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session index 4a98797ae..61702de28 100755 --- a/tools/linuxfs/overlay/usr/local/bin/bannerlator-session +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-session @@ -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 diff --git a/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shortcuts b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shortcuts new file mode 100755 index 000000000..98debbcf7 --- /dev/null +++ b/tools/linuxfs/overlay/usr/local/bin/bannerlator-steam-shortcuts @@ -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 + +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//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("