fix(steam): share the Steam app's prefix with shortcuts of the same install (#2463)

* fix(steam): share the Steam app's prefix with shortcuts of the same install

* fix(steam): prefer the matching install's save prefix

* fix(steam): tolerate an unreadable library when linking a shortcut prefix

A library folder that cannot be listed made the shortcut writer exit, so no added game was registered.

* fix(steam): repair a dangling shortcut prefix link and keep Steam library installs to their own prefix

* fix(steam): only change shortcut prefix links the importer created

* fix(steam): record a prefix link only when the importer created it
This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-11-12 12:11:45 -05:00
1 parent 52bf19ad8e
commit a1dfb96708
3 files changed
+239 -3

No files matched your search

+6
View File
@@ -52,6 +52,12 @@ again. A failed title lookup undoes nothing. The launcher likewise drops artwork
fetched for the app the files named before and fetches it again; pictures the user chose
are kept.
A game that stays a shortcut but sits in a Steam install directory with a single matching
manifest links its shortcut prefix to that app's existing `compatdata/<appid>`, so cloud
saves the client downloaded are the ones the shortcut sees. A Steam library install only
uses its own library's prefix. An existing shortcut prefix is never replaced, a link to a
missing prefix is removed first, and nothing is copied.
The account's `.droiddeck-routes.json` maps shortcut IDs to real Steam app IDs so the
Android launcher uses the same title and Proton prefix as Steam. Other accounts do not
inherit those routes. Managed shortcuts are replaced; user-created shortcuts and
@@ -71,7 +71,11 @@ def store_game(game):
def manifest_ids(steamapps, name):
matches = []
for manifest in Path(steamapps).glob("appmanifest_*.acf"):
try:
manifests = list(Path(steamapps).glob("appmanifest_*.acf"))
except OSError:
return matches
for manifest in manifests:
try:
install = re.search(r'"installdir"\s*"([^"\\]*)"', manifest.read_text(), re.I)
if install and install[1] == name:
@@ -81,13 +85,21 @@ def manifest_ids(steamapps, name):
return matches
def identify(game):
def manifest_identity(game):
folder = Path(game["folder"])
steamapps = folder.parent.parent
for library in ([steamapps] if folder.parent.name == "common" and steamapps.name == "steamapps" else []) + [folder.parent / WINNATIVE]:
matches = [appid for appid in manifest_ids(library, folder.name) if appid != SPACEWAR]
if matches:
return matches[0] if len(matches) == 1 else None
return (matches[0], library) if len(matches) == 1 else None
return False
def identify(game):
folder = Path(game["folder"])
found = manifest_identity(game)
if found is not False:
return found[0] if found is not None else None
ids = set()
for parent in {folder, Path(game["exe"]).parent}:
try:
@@ -508,6 +520,57 @@ def release(steam_root, game, stale, reason):
print(f"droiddeck-steam-games: {game.get('name', appid)} {reason}; it is no longer Steam app {appid}")
PREFIX_LINKS = ".droiddeck-prefix-links.json"
def steam_prefix(steam_root, game):
identity = manifest_identity(game)
if not identity or not identity[0]:
return None, None
appid, steamapps = identity
installed = steamapps == Path(game["folder"]).parent.parent
for library in [steamapps.parent] if installed else dict.fromkeys([steamapps.parent] + libraries(steam_root)):
prefix = library / "steamapps/compatdata" / str(appid)
if (prefix / "pfx/drive_c").is_dir():
return appid, str(prefix)
return appid, None
def share_prefix(steam_root, game):
shortcut = Path(steam_root) / "steamapps/compatdata" / str(game["appid"] & 0xFFFFFFFF)
record = Path(steam_root) / "steamapps" / PREFIX_LINKS
links = read_json(record, {})
if not isinstance(links, dict):
links = {}
before = dict(links)
key = str(shortcut)
name = game.get("name", "game")
appid, target = steam_prefix(steam_root, game)
try:
current = os.readlink(shortcut) if os.path.islink(shortcut) else None
if current is not None and links.get(key) == current and current != target:
if target is not None or (not os.path.exists(shortcut) and Path(current).parent.parent.is_dir()):
shortcut.unlink()
current = None
if current is None:
links.pop(key, None)
if current is None and os.path.lexists(shortcut):
if target is not None:
print(f"droiddeck-steam-games: {name} keeps its own prefix; Steam app {appid} saves are in {target}")
elif current is None and target is not None:
shortcut.parent.mkdir(parents=True, exist_ok=True)
shortcut.symlink_to(target)
links[key] = target
print(f"droiddeck-steam-games: {name} shares the saves prefix of Steam app {appid}")
except OSError as error:
print(f"droiddeck-steam-games: {name} keeps its own prefix ({error})")
if links != before:
try:
save_json(record, links)
except OSError as error:
print(f"droiddeck-steam-games: prefix links not recorded ({error})")
def route(steam_root, acct, games):
snapshot = read_json(Path(acct) / "config" / STATE, {})
if not isinstance(snapshot, dict):
@@ -557,6 +620,7 @@ def route(steam_root, acct, games):
elif named and seeded(steamapps, game, appid):
routes[str(game["appid"])] = appid
continue
share_prefix(steam_root, game)
shortcuts.append(game)
try:
park(steamapps)
+166
View File
@@ -3,6 +3,7 @@ import importlib.util
import json
import os
from pathlib import Path
import shutil
import subprocess
import tempfile
import unittest
@@ -56,6 +57,171 @@ class ImportsTest(unittest.TestCase):
self.assertEqual(self.folder, (self.steam / 'steamapps/common/DroidDeck-42').resolve())
self.assertEqual(b'game', self.exe.read_bytes())
def steam_install(self, appid=42):
library = self.root / 'Library'
folder = library / 'steamapps/common/Example'
folder.mkdir(parents=True)
(folder / 'Example.exe').write_bytes(b'game')
(library / f'steamapps/appmanifest_{appid}.acf').write_text(f'"AppState"\n{{\n"appid" "{appid}"\n"installdir" "Example"\n}}\n')
saves = library / f'steamapps/compatdata/{appid}/pfx/drive_c/users/steamuser'
saves.mkdir(parents=True)
(saves / 'save.dat').write_bytes(b'cloud')
return dict(self.game, folder=str(folder), exe=str(folder / 'Example.exe'), dir=str(folder)), library
def shortcut_prefix(self):
return self.steam / 'steamapps/compatdata' / str(self.game['appid'] & 0xFFFFFFFF)
def link_shortcut_prefix(self, target, owned=True):
(self.steam / 'steamapps/compatdata').mkdir(parents=True, exist_ok=True)
self.shortcut_prefix().symlink_to(target)
if owned:
imports.save_json(self.steam / 'steamapps' / imports.PREFIX_LINKS,
{str(self.shortcut_prefix()): str(target)})
def test_shortcut_of_a_steam_install_shares_the_steam_apps_prefix(self):
game, library = self.steam_install()
(self.steam / 'steamapps').mkdir(parents=True)
(self.steam / 'steamapps/libraryfolders.vdf').write_text(f'"libraryfolders"\n{{\n"1" {{ "path" "{library}" }}\n}}\n')
games, routes = imports.route(self.steam, self.acct, [game])
self.assertEqual(([game], {}), (games, routes))
self.assertEqual(library / 'steamapps/compatdata/42', self.shortcut_prefix().resolve())
self.assertEqual(b'cloud', (self.shortcut_prefix() / 'pfx/drive_c/users/steamuser/save.dat').read_bytes())
def test_matching_install_prefix_precedes_an_older_prefix_in_another_library(self):
game, library = self.steam_install()
(self.steam / 'steamapps').mkdir(parents=True)
(self.steam / 'steamapps/libraryfolders.vdf').write_text(f'"libraryfolders"\n{{\n"1" {{ "path" "{library}" }}\n}}\n')
older = self.steam / 'steamapps/compatdata/42/pfx/drive_c/users/steamuser'
older.mkdir(parents=True)
(older / 'save.dat').write_bytes(b'old')
imports.route(self.steam, self.acct, [game])
self.assertEqual(library / 'steamapps/compatdata/42', self.shortcut_prefix().resolve())
self.assertEqual(b'cloud', (self.shortcut_prefix() / 'pfx/drive_c/users/steamuser/save.dat').read_bytes())
self.assertEqual(b'old', (older / 'save.dat').read_bytes())
def test_an_existing_shortcut_prefix_is_never_replaced(self):
game, library = self.steam_install()
(self.steam / 'steamapps').mkdir(parents=True, exist_ok=True)
(self.steam / 'steamapps/libraryfolders.vdf').write_text(f'"libraryfolders"\n{{\n"1" {{ "path" "{library}" }}\n}}\n')
local = self.shortcut_prefix() / 'pfx/drive_c'
local.mkdir(parents=True)
(local / 'local.dat').write_bytes(b'local')
imports.route(self.steam, self.acct, [game])
self.assertFalse(self.shortcut_prefix().is_symlink())
self.assertEqual(b'local', (local / 'local.dat').read_bytes())
self.assertEqual(b'cloud', (library / 'steamapps/compatdata/42/pfx/drive_c/users/steamuser/save.dat').read_bytes())
def test_shortcuts_without_a_manifest_identity_keep_their_own_prefix(self):
game, library = self.steam_install()
(self.steam / 'steamapps').mkdir(parents=True, exist_ok=True)
(self.steam / 'steamapps/libraryfolders.vdf').write_text(f'"libraryfolders"\n{{\n"1" {{ "path" "{library}" }}\n}}\n')
imports.route(self.steam, self.acct, [self.game, dict(game, folder=str(self.folder.parent / 'Other'))])
self.assertFalse(os.path.lexists(self.shortcut_prefix()))
def test_a_manifest_without_a_steam_prefix_links_nothing(self):
game, library = self.steam_install()
shutil.rmtree(library / 'steamapps/compatdata/42')
(self.steam / 'steamapps').mkdir(parents=True, exist_ok=True)
(self.steam / 'steamapps/libraryfolders.vdf').write_text(f'"libraryfolders"\n{{\n"1" {{ "path" "{library}" }}\n}}\n')
imports.route(self.steam, self.acct, [game])
self.assertFalse(os.path.lexists(self.shortcut_prefix()))
def test_an_older_prefix_in_another_library_is_not_linked_for_a_steam_install(self):
game, library = self.steam_install()
shutil.rmtree(library / 'steamapps/compatdata/42')
(self.steam / 'steamapps').mkdir(parents=True, exist_ok=True)
(self.steam / 'steamapps/libraryfolders.vdf').write_text(f'"libraryfolders"\n{{\n"1" {{ "path" "{library}" }}\n}}\n')
(self.steam / 'steamapps/compatdata/42/pfx/drive_c').mkdir(parents=True)
imports.route(self.steam, self.acct, [game])
self.assertFalse(os.path.lexists(self.shortcut_prefix()))
def test_a_dangling_shortcut_prefix_link_is_replaced(self):
game, library = self.steam_install()
self.link_shortcut_prefix(self.root / 'moved')
imports.route(self.steam, self.acct, [game])
self.assertEqual(library / 'steamapps/compatdata/42', self.shortcut_prefix().resolve())
def test_a_dangling_shortcut_prefix_link_is_removed_without_a_replacement(self):
game, library = self.steam_install()
shutil.rmtree(library / 'steamapps/compatdata/42')
self.link_shortcut_prefix(self.root / 'moved')
imports.route(self.steam, self.acct, [game])
self.assertFalse(os.path.lexists(self.shortcut_prefix()))
def test_a_link_the_user_made_is_never_changed(self):
game, library = self.steam_install()
for target in (self.root / 'moved', library / 'steamapps/compatdata/42', self.folder):
if os.path.lexists(self.shortcut_prefix()):
self.shortcut_prefix().unlink()
self.link_shortcut_prefix(target, owned=False)
imports.route(self.steam, self.acct, [game])
self.assertEqual(str(target), os.readlink(self.shortcut_prefix()))
def test_a_user_link_to_the_matching_prefix_is_never_recorded_or_removed(self):
game, library = self.steam_install()
target = library / 'steamapps/compatdata/42'
self.link_shortcut_prefix(target, owned=False)
imports.route(self.steam, self.acct, [game])
self.assertEqual(str(target), os.readlink(self.shortcut_prefix()))
self.assertNotIn(str(self.shortcut_prefix()), imports.read_json(self.steam / 'steamapps' / imports.PREFIX_LINKS, {}))
shutil.rmtree(target)
imports.route(self.steam, self.acct, [game])
self.assertTrue(os.path.islink(self.shortcut_prefix()))
self.assertEqual(str(target), os.readlink(self.shortcut_prefix()))
def test_our_link_survives_a_library_that_is_temporarily_missing(self):
game, library = self.steam_install()
imports.route(self.steam, self.acct, [game])
hidden = self.root / 'Hidden'
library.rename(hidden)
imports.route(self.steam, self.acct, [game])
self.assertTrue(os.path.islink(self.shortcut_prefix()))
hidden.rename(library)
imports.route(self.steam, self.acct, [game])
self.assertEqual(library / 'steamapps/compatdata/42', self.shortcut_prefix().resolve())
self.assertEqual(b'cloud', (self.shortcut_prefix() / 'pfx/drive_c/users/steamuser/save.dat').read_bytes())
def test_our_link_is_dropped_once_its_prefix_is_deleted(self):
game, library = self.steam_install()
imports.route(self.steam, self.acct, [game])
shutil.rmtree(library / 'steamapps/compatdata/42')
imports.route(self.steam, self.acct, [game])
self.assertFalse(os.path.lexists(self.shortcut_prefix()))
self.assertEqual({}, imports.read_json(self.steam / 'steamapps' / imports.PREFIX_LINKS, None))
def test_a_replaced_link_record_is_forgotten_when_the_user_makes_a_real_prefix(self):
game, library = self.steam_install()
imports.route(self.steam, self.acct, [game])
self.shortcut_prefix().unlink()
(self.shortcut_prefix() / 'pfx/drive_c').mkdir(parents=True)
imports.route(self.steam, self.acct, [game])
self.assertFalse(self.shortcut_prefix().is_symlink())
self.assertEqual({}, imports.read_json(self.steam / 'steamapps' / imports.PREFIX_LINKS, None))
def test_a_shortcut_prefix_with_its_own_saves_is_reported_and_kept(self):
game, library = self.steam_install()
local = self.shortcut_prefix() / 'pfx/drive_c'
local.mkdir(parents=True)
with patch('builtins.print') as printed:
imports.route(self.steam, self.acct, [game])
self.assertTrue(local.is_dir())
self.assertIn('keeps its own prefix', printed.call_args_list[-1][0][0])
def test_a_winnative_game_shares_the_prefix_the_steam_client_uses(self):
self.winnative_manifest()
(self.steam / 'steamapps/libraryfolders.vdf').parent.mkdir(parents=True, exist_ok=True)
saves = self.steam / 'steamapps/compatdata/42/pfx/drive_c/users/steamuser'
saves.mkdir(parents=True)
(saves / 'save.dat').write_bytes(b'cloud')
imports.share_prefix(self.steam, self.game)
self.assertEqual(self.steam / 'steamapps/compatdata/42', self.shortcut_prefix().resolve())
def test_an_unreadable_library_does_not_stop_the_routing(self):
with patch.object(Path, 'glob', side_effect=PermissionError):
games, routes = imports.route(self.steam, self.acct, [self.game])
self.assertEqual([self.game], games)
self.assertFalse(os.path.lexists(self.shortcut_prefix()))
def test_store_installs_stay_shortcuts_even_when_owned_on_steam(self):
self.snapshot()
game = dict(self.game, store='amazon')