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:
MaxsTechReview authored and GitHub committed 2026-10-07 09:32:15 -04:00
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.
+169
View File
@@ -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()