mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-10-14 19:27:09 -04:00
Keep the shortcut settings changed in Steam (#393)
Steam saves a shortcut's Properties to shortcuts.vdf as soon as they change and keeps its appid. The session rewrote every added game's entry from the app's list before and after each client run, keeping only the launch options, so a Target or Start In set in Steam, a new name or icon, the hidden and overlay switches, the last played time and Steam's sort name were all put back at the next exit. droiddeck-steam-shortcuts now merges instead: an entry keeps every field Steam has, and the app's exe is written only where the app changed it. What the app last asked for and what Steam then had are recorded beside shortcuts.vdf (.droiddeck-shortcuts.json). A Target changed in Steam to a file inside the game's folder becomes the exe chosen in Steam > Settings > Games, and a later pick there replaces it in Steam, so both show the same exe. The appid now stays with the folder, so changing the exe no longer turns the game into a new one with a new prefix, playtime and launch options. A game whose folder is missing is set aside with its settings and comes back with them. The exit-time merge runs before Steam's logs are copied.
This commit is contained in:
1 parent
6d0484941e
commit
453f6f5180
7 files changed
+466
-37
No files matched your search
@@ -1366,7 +1366,7 @@ class MainActivity : ComponentActivity() {
|
||||
|
||||
/** Walks the added-games folders, which can sit on slow shared storage or an SD card. */
|
||||
private fun scanAddedGames() = com.droiddeck.launcher.frontend.AddedGames.scan(this).map { g ->
|
||||
com.droiddeck.launcher.ui.AddedGameRow(g.folder.path, g.folderName(), g.exe.path, g.exe.name, g.candidates.map { c -> c.path to c.name }.distinctBy { it.first })
|
||||
com.droiddeck.launcher.ui.AddedGameRow(g.folder.path, g.folderName(), g.exe.path, g.exe.name, (g.candidates + g.exe).map { c -> c.path to c.name }.distinctBy { it.first })
|
||||
}
|
||||
|
||||
private fun fetchAddedGameArt() {
|
||||
|
||||
@@ -126,32 +126,59 @@ object AddedGames {
|
||||
private fun scanGame(context: Context, folder: File, out: MutableList<Game>) {
|
||||
run {
|
||||
val candidates = candidates(folder)
|
||||
val chosen = SessionPrefs.addedGameExe(context, folder.path).takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
|
||||
val exe = chosen ?: candidates.firstOrNull() ?: return
|
||||
val guestExe = guestPath(context, exe)
|
||||
if (guestExe == null) { Log.w(TAG, "${folder.name}: the session cannot see ${exe.path}"); return }
|
||||
val guestDir = guestPath(context, exe.parentFile ?: folder) ?: return
|
||||
val picked = SessionPrefs.addedGameExe(context, folder.path)
|
||||
val chosen = picked.takeIf { it.isNotEmpty() }?.let { File(it) }?.takeIf { it.isFile }
|
||||
val found = chosen ?: candidates.firstOrNull() ?: return
|
||||
val foundGuest = guestPath(context, found)
|
||||
if (foundGuest == null) { Log.w(TAG, "${folder.name}: the session cannot see ${found.path}"); return }
|
||||
val name = folder.name
|
||||
// Keyed by the pre-rename path so shortcut ids, and the prefixes and saves under them, stay put.
|
||||
val crc = CRC32().apply { update(("\"${guestExe.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + name).toByteArray()) }.value
|
||||
val appId = crc or 0x80000000L
|
||||
val steamRoot = File(LinuxRuntime.rootDir(context), "root/.local/share/Steam")
|
||||
val steamId = steamRoute(steamRoot, appId)
|
||||
val crc = CRC32().apply { update(("\"${foundGuest.replaceFirst(Regex("^$LIBRARY/"), "$LEGACY_LIBRARY/")}\"" + name).toByteArray()) }.value
|
||||
val appId = SessionPrefs.addedGameAppId(context, folder.path, crc or 0x80000000L)
|
||||
val config = accountConfig(File(LinuxRuntime.rootDir(context), "root/.local/share/Steam"))
|
||||
val record = config?.let { shortcutRecord(it, appId) }
|
||||
val rev = record?.optInt("rev", 0) ?: 0
|
||||
val adopted = guestPath(context, folder)
|
||||
?.let { steamTarget(folder, it, record, foundGuest, SessionPrefs.addedGameExeSeen(context, folder.path)) }
|
||||
?.takeIf { SessionPrefs.adoptAddedGameExe(context, folder.path, picked, it.path, rev) }
|
||||
val exe = adopted ?: found
|
||||
val guestExe = if (adopted != null) guestPath(context, adopted) ?: return else foundGuest
|
||||
val guestDir = guestPath(context, exe.parentFile ?: folder) ?: return
|
||||
val steamId = config?.let { steamRoute(it, appId) }
|
||||
out.add(Game(folder, name, exe, guestExe, guestDir, appId,
|
||||
steamId?.toLong() ?: ((appId shl 32) or 0x02000000L), candidates, steamId))
|
||||
}
|
||||
}
|
||||
|
||||
private fun steamRoute(root: File, appId: Long): Int? = runCatching {
|
||||
val users = File(root, "config/loginusers.vdf").readText()
|
||||
val blocks = Regex(""""(\d{5,})"\s*\{([^}]*)\}""")
|
||||
val recent = blocks.findAll(users).firstOrNull { Regex(""""MostRecent"\s*"1"""").containsMatchIn(it.groupValues[2]) }
|
||||
?: return@runCatching null
|
||||
val account = recent.groupValues[1].toLong() - 76561197960265728L
|
||||
JSONObject(File(root, "userdata/$account/config/.droiddeck-routes.json").readText())
|
||||
.optInt(appId.toString()).takeIf { it > 0 }
|
||||
private fun accountConfig(root: File): File? {
|
||||
val users = runCatching { File(root, "config/loginusers.vdf").readText() }.getOrDefault("")
|
||||
val recent = Regex(""""(\d{5,})"\s*\{([^}]*)\}""").findAll(users)
|
||||
.lastOrNull { Regex(""""MostRecent"\s*"1"""").containsMatchIn(it.groupValues[2]) }
|
||||
val account = recent?.groupValues?.get(1)?.toLongOrNull()?.let { (it - 76561197960265728L).toString() }
|
||||
?: File(root, "userdata").list()?.filter { it.isNotEmpty() && it.all(Char::isDigit) && it != "0" }?.singleOrNull()
|
||||
return account?.let { File(root, "userdata/$it/config") }
|
||||
}
|
||||
|
||||
private fun steamRoute(config: File, appId: Long): Int? = runCatching {
|
||||
JSONObject(File(config, ".droiddeck-routes.json").readText()).optInt(appId.toString()).takeIf { it > 0 }
|
||||
}.getOrNull()
|
||||
|
||||
private fun shortcutRecord(config: File, appId: Long): JSONObject? = runCatching {
|
||||
JSONObject(File(config, ".droiddeck-shortcuts.json").readText()).optJSONObject("games")?.optJSONObject(appId.toString())
|
||||
}.getOrNull()
|
||||
|
||||
internal fun steamTarget(folder: File, folderGuest: String, record: JSONObject?, guestExe: String, seen: Int): File? {
|
||||
if (record == null || record.optInt("rev", 0) == seen) return null
|
||||
val asked = unquote(record.optJSONObject("app")?.optString("Exe"))
|
||||
val steam = unquote(record.optJSONObject("steam")?.optString("Exe"))
|
||||
if (asked != guestExe || steam.isEmpty() || steam == asked || !steam.startsWith("$folderGuest/")) return null
|
||||
val file = File(folder, steam.removePrefix("$folderGuest/"))
|
||||
val inside = runCatching { file.canonicalPath.startsWith(folder.canonicalPath + File.separator) }.getOrDefault(false)
|
||||
return file.takeIf { inside && it.isFile }
|
||||
}
|
||||
|
||||
private fun unquote(value: String?): String = value.orEmpty().trim().trim('"')
|
||||
|
||||
/** 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() }
|
||||
@@ -161,6 +188,7 @@ object AddedGames {
|
||||
json.append("{\"name\":").append(quote(g.name)).append(",\"exe\":").append(quote(g.guestExe))
|
||||
.append(",\"folder\":").append(quote(guestPath(context, g.folder) ?: g.guestDir))
|
||||
.append(",\"dir\":").append(quote(g.guestDir)).append(",\"appid\":").append(g.appId)
|
||||
.append(",\"seen\":").append(SessionPrefs.addedGameExeSeen(context, g.folder.path))
|
||||
// The art, as the session sees it: the app's cache is bound at its own path, a file in
|
||||
// the game's folder at the folder's guest path.
|
||||
val art = AddedGameArt.resolve(context, g)
|
||||
|
||||
@@ -593,8 +593,25 @@ object SessionPrefs {
|
||||
/** The .exe the user chose for one game folder (by its path), "" = the scanner's pick. */
|
||||
fun addedGameExe(context: Context, folderPath: String): String = prefs(context).getString("addedExe:$folderPath", "") ?: ""
|
||||
|
||||
private val addedGameLock = Any()
|
||||
|
||||
fun setAddedGameExe(context: Context, folderPath: String, path: String) {
|
||||
prefs(context).edit().putString("addedExe:$folderPath", path).apply()
|
||||
synchronized(addedGameLock) { prefs(context).edit().putString("addedExe:$folderPath", path).apply() }
|
||||
}
|
||||
|
||||
fun addedGameExeSeen(context: Context, folderPath: String): Int = prefs(context).getInt("addedExeSeen:$folderPath", 0)
|
||||
|
||||
fun adoptAddedGameExe(context: Context, folderPath: String, expected: String, path: String, seen: Int): Boolean =
|
||||
synchronized(addedGameLock) {
|
||||
val unchanged = addedGameExe(context, folderPath) == expected
|
||||
if (unchanged) prefs(context).edit().putString("addedExe:$folderPath", path).putInt("addedExeSeen:$folderPath", seen).apply()
|
||||
unchanged
|
||||
}
|
||||
|
||||
fun addedGameAppId(context: Context, folderPath: String, first: Long): Long = synchronized(addedGameLock) {
|
||||
val p = prefs(context)
|
||||
p.getLong("addedAppId:$folderPath", 0L).takeIf { it != 0L }
|
||||
?: first.also { p.edit().putLong("addedAppId:$folderPath", it).apply() }
|
||||
}
|
||||
|
||||
/** The app's colour theme (ui/Themes ids); Graphite unless chosen otherwise. */
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.droiddeck.launcher.frontend
|
||||
|
||||
import android.content.Context
|
||||
import com.droiddeck.launcher.runtime.LinuxRuntime
|
||||
import com.droiddeck.launcher.session.SessionPrefs
|
||||
import java.io.File
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
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
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [28], manifest = Config.NONE)
|
||||
class AddedGamesSteamEditsTest {
|
||||
@get:Rule val tmp = TemporaryFolder()
|
||||
private lateinit var context: Context
|
||||
private lateinit var games: File
|
||||
private lateinit var folder: File
|
||||
private lateinit var main: File
|
||||
private lateinit var other: File
|
||||
|
||||
@Before fun setUp() {
|
||||
context = RuntimeEnvironment.getApplication()
|
||||
games = tmp.newFolder("PC")
|
||||
folder = File(games, "Example").apply { mkdirs() }
|
||||
main = File(folder, "Example.exe").apply { writeBytes(ByteArray(64)) }
|
||||
other = File(folder, "bin/Other.exe").apply { parentFile!!.mkdirs(); writeBytes(ByteArray(8)) }
|
||||
SessionPrefs.setGameStorage(context, SessionPrefs.GAME_STORAGE_OFF, "")
|
||||
SessionPrefs.setAddedGamesDirs(context, listOf(games.path))
|
||||
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/userdata/123/config").mkdirs()
|
||||
}
|
||||
|
||||
private fun record(appId: Long, asked: String, steam: String, rev: Int) {
|
||||
val game = JSONObject()
|
||||
.put("app", JSONObject().put("Exe", "\"$asked\""))
|
||||
.put("steam", JSONObject().put("Exe", "\"$steam\""))
|
||||
.put("rev", rev)
|
||||
File(LinuxRuntime.rootDir(context), "root/.local/share/Steam/userdata/123/config/.droiddeck-shortcuts.json")
|
||||
.writeText(JSONObject().put("games", JSONObject().put(appId.toString(), game)).toString())
|
||||
}
|
||||
|
||||
private fun scanned() = AddedGames.scan(context).single { it.folder == folder }
|
||||
|
||||
@Test fun aTargetChangedInSteamBecomesTheAppsChoiceOnce() {
|
||||
val first = scanned()
|
||||
assertEquals(main, first.exe)
|
||||
record(first.appId, "/root/Games/PC/Example/Example.exe", "/root/Games/PC/Example/bin/Other.exe", 1)
|
||||
|
||||
val adopted = scanned()
|
||||
assertEquals(other, adopted.exe)
|
||||
assertEquals("/root/Games/PC/Example/bin/Other.exe", adopted.guestExe)
|
||||
assertEquals(first.appId, adopted.appId)
|
||||
assertEquals(other.path, SessionPrefs.addedGameExe(context, folder.path))
|
||||
assertEquals(1, SessionPrefs.addedGameExeSeen(context, folder.path))
|
||||
|
||||
SessionPrefs.setAddedGameExe(context, folder.path, main.path)
|
||||
val chosen = scanned()
|
||||
assertEquals(main, chosen.exe)
|
||||
assertEquals(first.appId, chosen.appId)
|
||||
assertEquals(main.path, SessionPrefs.addedGameExe(context, folder.path))
|
||||
}
|
||||
|
||||
@Test fun theListingTellsTheSessionWhichSteamEditTheAppHasTaken() {
|
||||
val first = scanned()
|
||||
record(first.appId, "/root/Games/PC/Example/Example.exe", "/root/Games/PC/Example/bin/Other.exe", 3)
|
||||
val listing = JSONObject(AddedGames.writeListing(context, listOf(scanned())).readText().removePrefix("[").removeSuffix("]"))
|
||||
assertEquals("/root/Games/PC/Example/bin/Other.exe", listing.getString("exe"))
|
||||
assertEquals("/root/Games/PC/Example/bin", listing.getString("dir"))
|
||||
assertEquals(3, listing.getInt("seen"))
|
||||
assertEquals(first.appId, listing.getLong("appid"))
|
||||
}
|
||||
|
||||
@Test fun theAppidStaysWithTheFolderWhenItsExeChanges() {
|
||||
val first = scanned()
|
||||
SessionPrefs.setAddedGameExe(context, folder.path, other.path)
|
||||
val changed = scanned()
|
||||
assertEquals(other, changed.exe)
|
||||
assertEquals(first.appId, changed.appId)
|
||||
assertEquals(first.gameId, changed.gameId)
|
||||
}
|
||||
|
||||
@Test fun aSteamTargetOutsideTheFolderIsNotTaken() {
|
||||
val outside = File(games, "Elsewhere/Game.exe").apply { parentFile!!.mkdirs(); writeBytes(ByteArray(8)) }
|
||||
val first = scanned()
|
||||
record(first.appId, "/root/Games/PC/Example/Example.exe", "/root/Games/PC/Example/../Elsewhere/Game.exe", 1)
|
||||
assertEquals(main, scanned().exe)
|
||||
assertEquals("", SessionPrefs.addedGameExe(context, folder.path))
|
||||
assertEquals(0, SessionPrefs.addedGameExeSeen(context, folder.path))
|
||||
assertTrue(outside.isFile)
|
||||
}
|
||||
|
||||
@Test fun steamTargetNeedsANewEditThatStartsFromTheAppsExe() {
|
||||
val guest = "/root/Games/PC/Example"
|
||||
fun game(asked: String, steam: String, rev: Int) = JSONObject()
|
||||
.put("app", JSONObject().put("Exe", "\"$guest/$asked\""))
|
||||
.put("steam", JSONObject().put("Exe", "\"$guest/$steam\""))
|
||||
.put("rev", rev)
|
||||
assertEquals(other, AddedGames.steamTarget(folder, guest, game("Example.exe", "bin/Other.exe", 2), "$guest/Example.exe", 1))
|
||||
assertNull(AddedGames.steamTarget(folder, guest, game("Example.exe", "bin/Other.exe", 2), "$guest/Example.exe", 2))
|
||||
assertNull(AddedGames.steamTarget(folder, guest, game("Example.exe", "bin/Other.exe", 2), "$guest/bin/Other.exe", 0))
|
||||
assertNull(AddedGames.steamTarget(folder, guest, game("Example.exe", "Example.exe", 2), "$guest/Example.exe", 0))
|
||||
assertNull(AddedGames.steamTarget(folder, guest, game("Example.exe", "Missing.exe", 2), "$guest/Example.exe", 0))
|
||||
assertNull(AddedGames.steamTarget(folder, guest, null, "$guest/Example.exe", 0))
|
||||
}
|
||||
}
|
||||
@@ -839,13 +839,13 @@ BUSCONF
|
||||
-cef-use-gl=angle -cef-use-angle=vulkan "$@"
|
||||
rc=$?
|
||||
echo "== steam exited rc=$rc"
|
||||
if [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then
|
||||
droiddeck-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true
|
||||
fi
|
||||
collect_steam_logs
|
||||
if [ -f "$HOME/.config/droiddeck/fex-wanted" ] && [ -x /usr/local/bin/droiddeck-fex ]; then
|
||||
/usr/local/bin/droiddeck-fex status >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [ -n "${BL_ADDED_GAMES:-}" ] && [ -f "$BL_ADDED_GAMES" ]; then
|
||||
droiddeck-steam-shortcuts "$steam_root" "$BL_ADDED_GAMES" || true
|
||||
fi
|
||||
if [ -f "$HOME/.bl-stop-requested" ]; then
|
||||
rm -f "$HOME/.bl-stop-requested" "$HOME/.bl-restart-once"
|
||||
break
|
||||
|
||||
@@ -120,6 +120,114 @@ def shortcut(game, launch_options=""):
|
||||
}
|
||||
|
||||
|
||||
RECORD = ".droiddeck-shortcuts.json"
|
||||
AWAY_LIMIT = 256
|
||||
FIELDS = ("AppName", "Exe", "StartDir", "icon")
|
||||
|
||||
|
||||
def unquoted(value):
|
||||
return value.strip().strip('"') if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def read_record(path):
|
||||
try:
|
||||
with open(path, encoding="utf-8") as f:
|
||||
record = json.load(f)
|
||||
except (OSError, ValueError):
|
||||
record = {}
|
||||
if not isinstance(record, dict):
|
||||
record = {}
|
||||
games, away = record.get("games"), record.get("away")
|
||||
return {
|
||||
"games": {k: v for k, v in games.items() if isinstance(v, dict)} if isinstance(games, dict) else {},
|
||||
"away": {k: v for k, v in away.items() if isinstance(v, dict) and isinstance(v.get("entry"), dict)} if isinstance(away, dict) else {},
|
||||
}
|
||||
|
||||
|
||||
def merge(fresh, current, state, seen):
|
||||
state = state if isinstance(state, dict) else {}
|
||||
rev = state.get("rev") if isinstance(state.get("rev"), int) else 0
|
||||
if current is None:
|
||||
return fresh, rev
|
||||
base = state.get("app") if isinstance(state.get("app"), dict) else {field: current.get(field) for field in FIELDS}
|
||||
before = state.get("steam") if isinstance(state.get("steam"), dict) else {"Exe": current.get("Exe")}
|
||||
if unquoted(current.get("Exe")) != unquoted(before.get("Exe")):
|
||||
rev += 1
|
||||
entry = dict(current)
|
||||
entry["appid"] = fresh["appid"]
|
||||
exe = unquoted(fresh["Exe"])
|
||||
if exe != unquoted(current.get("Exe")) and (exe != unquoted(base.get("Exe")) or seen == rev):
|
||||
entry["Exe"], entry["StartDir"] = fresh["Exe"], fresh["StartDir"]
|
||||
for field in ("AppName", "icon"):
|
||||
if current.get(field) == base.get(field):
|
||||
entry[field] = fresh[field]
|
||||
tags = dict(current["tags"]) if isinstance(current.get("tags"), dict) else {}
|
||||
if TAG not in tags.values():
|
||||
slot = 0
|
||||
while str(slot) in tags:
|
||||
slot += 1
|
||||
tags[str(slot)] = TAG
|
||||
entry["tags"] = tags
|
||||
for key, value in fresh.items():
|
||||
entry.setdefault(key, value)
|
||||
return entry, rev
|
||||
|
||||
|
||||
def reconcile(existing, wanted, seen, record):
|
||||
games, away = record["games"], dict(record["away"])
|
||||
kept, ours, placed = [], [], {}
|
||||
for value in existing.values():
|
||||
if not isinstance(value, dict):
|
||||
continue
|
||||
tags = value.get("tags")
|
||||
if not isinstance(tags, dict) or TAG not in tags.values():
|
||||
kept.append(value)
|
||||
continue
|
||||
appid = value.get("appid")
|
||||
if not isinstance(appid, int) or appid in placed:
|
||||
continue
|
||||
key = str(appid & 0xFFFFFFFF)
|
||||
fresh = wanted.get(appid)
|
||||
if fresh is None:
|
||||
away.pop(key, None)
|
||||
away[key] = {"entry": value, "state": games.get(key)}
|
||||
continue
|
||||
entry, rev = merge(fresh, value, games.get(key), seen.get(appid, 0))
|
||||
placed[appid] = (len(ours), rev)
|
||||
ours.append(entry)
|
||||
for appid, fresh in wanted.items():
|
||||
if appid in placed:
|
||||
continue
|
||||
key = str(appid & 0xFFFFFFFF)
|
||||
stashed = away.get(key)
|
||||
if stashed:
|
||||
entry, rev = merge(fresh, stashed["entry"], stashed.get("state"), seen.get(appid, 0))
|
||||
else:
|
||||
entry, rev = merge(fresh, None, games.get(key), seen.get(appid, 0))
|
||||
placed[appid] = (len(ours), rev)
|
||||
ours.append(entry)
|
||||
for appid in placed:
|
||||
away.pop(str(appid & 0xFFFFFFFF), None)
|
||||
while len(away) > AWAY_LIMIT:
|
||||
away.pop(next(iter(away)))
|
||||
state = {}
|
||||
for appid, (index, rev) in placed.items():
|
||||
entry = ours[index]
|
||||
state[str(appid & 0xFFFFFFFF)] = {
|
||||
"app": {field: wanted[appid][field] for field in FIELDS},
|
||||
"steam": {"Exe": entry.get("Exe", ""), "StartDir": entry.get("StartDir", "")},
|
||||
"rev": rev,
|
||||
}
|
||||
return kept, ours, {"games": state, "away": away}
|
||||
|
||||
|
||||
def save_record(path, record):
|
||||
tmp = path + ".droiddeck"
|
||||
with open(tmp, "w", encoding="utf-8") as f:
|
||||
json.dump(record, f)
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
ART_MANIFEST = ".droiddeck-art.json"
|
||||
# listing key -> the client's grid file suffix: <appid>p = portrait capsule, <appid> = header,
|
||||
# <appid>_hero, <appid>_logo.
|
||||
@@ -208,27 +316,21 @@ def main():
|
||||
routing = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(routing)
|
||||
games, routes = routing.route(steam_root, acct, games)
|
||||
# Steam saves Big Picture's per-shortcut launch options in shortcuts.vdf. Keep them when
|
||||
# refreshing our entries, keyed by the appid that stays stable across session starts.
|
||||
managed = {
|
||||
v.get("appid"): v for v in existing.values()
|
||||
if isinstance(v, dict) and TAG in (v.get("tags") or {}).values()
|
||||
}
|
||||
kept = [v for v in existing.values() if isinstance(v, dict) and TAG not in (v.get("tags") or {}).values()]
|
||||
ours = []
|
||||
wanted, seen = {}, {}
|
||||
for game in games:
|
||||
if not game.get("exe") or not game.get("name"):
|
||||
continue
|
||||
entry = shortcut(game)
|
||||
previous = managed.get(entry["appid"])
|
||||
if previous and isinstance(previous.get("LaunchOptions"), str):
|
||||
entry["LaunchOptions"] = previous["LaunchOptions"]
|
||||
ours.append(entry)
|
||||
if game.get("exe") and game.get("name"):
|
||||
entry = shortcut(game)
|
||||
if entry["appid"] not in wanted:
|
||||
wanted[entry["appid"]] = entry
|
||||
seen[entry["appid"]] = game["seen"] if isinstance(game.get("seen"), int) else 0
|
||||
record_path = os.path.join(config, RECORD)
|
||||
kept, ours, record = reconcile(existing, wanted, seen, read_record(record_path))
|
||||
entries = {str(i): v for i, v in enumerate(kept + ours)}
|
||||
tmp = path + ".droiddeck"
|
||||
with open(tmp, "wb") as f:
|
||||
f.write(write({"shortcuts": entries}))
|
||||
os.replace(tmp, path)
|
||||
save_record(record_path, record)
|
||||
print("droiddeck-steam-shortcuts: %d added game(s) written, %d of the client's own kept" % (len(ours), len(kept)))
|
||||
install_art(acct, [g for g in games if g.get("exe") and g.get("name")])
|
||||
# Each added game runs under the ARM64 Proton: the compat registrar's own mapping writer.
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import importlib.machinery
|
||||
import importlib.util
|
||||
import json
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
BIN = Path(__file__).resolve().parents[1] / 'linuxfs/overlay/usr/local/bin'
|
||||
|
||||
|
||||
def load(name):
|
||||
loader = importlib.machinery.SourceFileLoader(name, str(BIN / name))
|
||||
spec = importlib.util.spec_from_loader(name, loader)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
shortcuts = load('droiddeck-steam-shortcuts')
|
||||
|
||||
APPID = 0x87654321
|
||||
GUEST = '/root/Games/PC/Example'
|
||||
|
||||
|
||||
class ShortcutsTest(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.root = Path(self.temp.name)
|
||||
self.steam = self.root / 'Steam'
|
||||
self.config = self.steam / 'userdata/123/config'
|
||||
self.config.mkdir(parents=True)
|
||||
self.listing = self.root / 'added-games.json'
|
||||
|
||||
def game(self, exe='Example.exe', seen=0, appid=APPID, name='Example'):
|
||||
path = GUEST + '/' + exe
|
||||
return dict(name=name, exe=path, folder=GUEST, dir=path.rsplit('/', 1)[0], appid=appid, seen=seen)
|
||||
|
||||
def run_writer(self, *games):
|
||||
self.listing.write_text(json.dumps(list(games)))
|
||||
subprocess.run(['python3', str(BIN / 'droiddeck-steam-shortcuts'), str(self.steam), str(self.listing)],
|
||||
check=True, capture_output=True)
|
||||
return self.entries()
|
||||
|
||||
def entries(self):
|
||||
data = shortcuts.parse((self.config / 'shortcuts.vdf').read_bytes())['shortcuts']
|
||||
return {value['appid'] & 0xFFFFFFFF: value for value in data.values()}
|
||||
|
||||
def steam_edits(self, appid=APPID, **fields):
|
||||
path = self.config / 'shortcuts.vdf'
|
||||
data = shortcuts.parse(path.read_bytes())['shortcuts']
|
||||
for value in data.values():
|
||||
if value['appid'] & 0xFFFFFFFF == appid:
|
||||
value.update(fields)
|
||||
path.write_bytes(shortcuts.write({'shortcuts': data}))
|
||||
|
||||
def record(self):
|
||||
return json.loads((self.config / shortcuts.RECORD).read_text())
|
||||
|
||||
def test_settings_changed_in_steam_survive_its_restart(self):
|
||||
self.run_writer(self.game())
|
||||
edits = dict(Exe='"%s/bin/Other.exe"' % GUEST, StartDir='"%s"' % GUEST, LaunchOptions='DXVK_HUD=1 %command%',
|
||||
AppName='Example GOTY', icon='/root/custom.png', IsHidden=1, AllowOverlay=0, LastPlayTime=1791372037,
|
||||
sortas='example', tags={'0': shortcuts.TAG, '1': 'Favorites'})
|
||||
self.steam_edits(**edits)
|
||||
entry = self.run_writer(self.game())[APPID]
|
||||
for field, value in edits.items():
|
||||
self.assertEqual(value, entry[field], field)
|
||||
entry = self.run_writer(self.game())[APPID]
|
||||
for field, value in edits.items():
|
||||
self.assertEqual(value, entry[field], field)
|
||||
|
||||
def test_steam_edit_is_recorded_for_the_app(self):
|
||||
self.run_writer(self.game())
|
||||
self.steam_edits(Exe='"%s/bin/Other.exe"' % GUEST)
|
||||
self.run_writer(self.game())
|
||||
state = self.record()['games'][str(APPID)]
|
||||
self.assertEqual('"%s/Example.exe"' % GUEST, state['app']['Exe'])
|
||||
self.assertEqual('"%s/bin/Other.exe"' % GUEST, state['steam']['Exe'])
|
||||
self.assertEqual(1, state['rev'])
|
||||
self.run_writer(self.game())
|
||||
self.assertEqual(1, self.record()['games'][str(APPID)]['rev'])
|
||||
|
||||
def test_the_app_taking_steams_target_keeps_steams_start_in(self):
|
||||
self.run_writer(self.game())
|
||||
self.steam_edits(Exe='"%s/bin/Other.exe"' % GUEST, StartDir='"%s/data"' % GUEST)
|
||||
self.run_writer(self.game())
|
||||
entry = self.run_writer(self.game('bin/Other.exe', seen=1))[APPID]
|
||||
self.assertEqual('"%s/bin/Other.exe"' % GUEST, entry['Exe'])
|
||||
self.assertEqual('"%s/data"' % GUEST, entry['StartDir'])
|
||||
|
||||
def test_an_exe_chosen_in_the_app_replaces_steams_target(self):
|
||||
self.run_writer(self.game())
|
||||
self.steam_edits(Exe='"%s/bin/Other.exe"' % GUEST, LaunchOptions='-windowed')
|
||||
self.run_writer(self.game())
|
||||
entry = self.run_writer(self.game('tools/Launcher.exe'))[APPID]
|
||||
self.assertEqual('"%s/tools/Launcher.exe"' % GUEST, entry['Exe'])
|
||||
self.assertEqual('"%s/tools"' % GUEST, entry['StartDir'])
|
||||
self.assertEqual('-windowed', entry['LaunchOptions'])
|
||||
|
||||
def test_going_back_to_the_apps_exe_after_taking_steams_wins(self):
|
||||
self.run_writer(self.game())
|
||||
self.steam_edits(Exe='"%s/bin/Other.exe"' % GUEST)
|
||||
self.run_writer(self.game())
|
||||
entry = self.run_writer(self.game(seen=1))[APPID]
|
||||
self.assertEqual('"%s/Example.exe"' % GUEST, entry['Exe'])
|
||||
self.assertEqual('"%s"' % GUEST, entry['StartDir'])
|
||||
|
||||
def test_a_later_steam_edit_is_kept_until_the_app_takes_it(self):
|
||||
self.run_writer(self.game())
|
||||
self.steam_edits(Exe='"%s/bin/Other.exe"' % GUEST)
|
||||
self.run_writer(self.game())
|
||||
self.run_writer(self.game(seen=1))
|
||||
self.steam_edits(Exe='"%s/bin/Other.exe"' % GUEST)
|
||||
entry = self.run_writer(self.game(seen=1))[APPID]
|
||||
self.assertEqual('"%s/bin/Other.exe"' % GUEST, entry['Exe'])
|
||||
self.assertEqual(2, self.record()['games'][str(APPID)]['rev'])
|
||||
|
||||
def test_a_game_that_goes_away_comes_back_with_its_settings(self):
|
||||
self.run_writer(self.game())
|
||||
self.steam_edits(Exe='"%s/bin/Other.exe"' % GUEST, LaunchOptions='-dx11', LastPlayTime=1791372037)
|
||||
self.assertEqual({}, self.run_writer())
|
||||
self.assertIn(str(APPID), self.record()['away'])
|
||||
entry = self.run_writer(self.game())[APPID]
|
||||
self.assertEqual('"%s/bin/Other.exe"' % GUEST, entry['Exe'])
|
||||
self.assertEqual('-dx11', entry['LaunchOptions'])
|
||||
self.assertEqual(1791372037, entry['LastPlayTime'])
|
||||
self.assertEqual({}, self.record()['away'])
|
||||
|
||||
def test_first_run_keeps_steams_own_fields_and_writes_the_apps_paths(self):
|
||||
old = dict(shortcuts.shortcut(self.game('Old.exe')), LaunchOptions='-novid', LastPlayTime=5, sortas='x')
|
||||
mine = dict(shortcuts.shortcut(dict(self.game(appid=0x81111111), name='Mine')), tags={'0': 'Personal'})
|
||||
(self.config / 'shortcuts.vdf').write_bytes(shortcuts.write({'shortcuts': {'0': mine, '1': old}}))
|
||||
entries = self.run_writer(self.game())
|
||||
entry = entries[APPID]
|
||||
self.assertEqual('"%s/Example.exe"' % GUEST, entry['Exe'])
|
||||
self.assertEqual('-novid', entry['LaunchOptions'])
|
||||
self.assertEqual(5, entry['LastPlayTime'])
|
||||
self.assertEqual('x', entry['sortas'])
|
||||
self.assertEqual(mine, entries[0x81111111])
|
||||
|
||||
def test_a_missing_tag_is_put_back_without_losing_steams(self):
|
||||
self.run_writer(self.game())
|
||||
self.steam_edits(tags={'1': 'Favorites', '0': shortcuts.TAG})
|
||||
entry = self.run_writer(self.game())[APPID]
|
||||
self.assertEqual({'1': 'Favorites', '0': shortcuts.TAG}, entry['tags'])
|
||||
self.assertEqual({'0': shortcuts.TAG}, shortcuts.merge(shortcuts.shortcut(self.game()), dict(shortcuts.shortcut(self.game()), tags={}), None, 0)[0]['tags'])
|
||||
|
||||
def test_a_damaged_record_is_started_over(self):
|
||||
self.run_writer(self.game())
|
||||
(self.config / shortcuts.RECORD).write_text('{not json')
|
||||
self.steam_edits(LaunchOptions='-safe')
|
||||
entry = self.run_writer(self.game())[APPID]
|
||||
self.assertEqual('-safe', entry['LaunchOptions'])
|
||||
self.assertIn(str(APPID), self.record()['games'])
|
||||
|
||||
def test_games_set_aside_are_bounded(self):
|
||||
many = [self.game(appid=0x80000000 + n, name='Game %d' % n) for n in range(shortcuts.AWAY_LIMIT + 4)]
|
||||
self.run_writer(*many)
|
||||
self.run_writer()
|
||||
away = self.record()['away']
|
||||
self.assertEqual(shortcuts.AWAY_LIMIT, len(away))
|
||||
self.assertNotIn(str(0x80000000), away)
|
||||
self.assertIn(str(0x80000000 + shortcuts.AWAY_LIMIT + 3), away)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user