mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-13 03:51:10 +00:00
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:
1 parent
52bf19ad8e
commit
a1dfb96708
3 files changed
+239
-3
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in new issue
Block a user