From cc7b8a4d5e01687748b0c895757486dce3dfda3e Mon Sep 17 00:00:00 2001 From: dime-online Date: Tue, 6 Oct 2026 00:17:47 -0400 Subject: [PATCH] fix: remove a renamed native shortcut during uninstall --- resources/steam_shortcut.py | 14 +++++++----- tests/test_uninstall.py | 43 +++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 5 deletions(-) diff --git a/resources/steam_shortcut.py b/resources/steam_shortcut.py index 007238a..1672db6 100644 --- a/resources/steam_shortcut.py +++ b/resources/steam_shortcut.py @@ -304,7 +304,7 @@ def update_shortcut(data: bytes | None, executable: str, directory: str) -> tupl def remove_shortcut(data: bytes, executable: str) -> tuple[bytes, list[int]]: - """Remove only exact native title/path matches; keep all foreign fields.""" + """Remove only entries that launch the native executable; keep all foreign fields.""" root = loads(data) shortcuts = root.get("shortcuts") if shortcuts is None or shortcuts.kind != 0: @@ -314,13 +314,17 @@ def remove_shortcut(data: bytes, executable: str) -> tuple[bytes, list[int]]: if entry.kind != 0: raise ValueError("Unexpected Steam shortcut record; no changes were made.") fields = entry.value - title = fields.get("AppName", fields.get("appname")) path = fields.get("Exe", fields.get("exe")) - if (title is not None and title.kind == 1 and title.value == NAME - and path is not None and path.kind == 1 and path.value in (executable, f'"{executable}"')): + # Ownership is the native executable, not the display title: Steam + # lets users rename library entries, and the live client path treats + # an entry pointing at the native executable as owned as well. Match + # the path exactly, bare or quoted once, like the live client does. + # A malformed app ID leaves the entry in place instead of aborting + # the removal of the valid ones. + if path is not None and path.kind == 1 and path.value in (executable, f'"{executable}"'): appid = fields.get("appid") if appid is None or appid.kind != 2 or not 2 ** 31 <= appid.value < 2 ** 32: - raise ValueError("The native Halo shortcut has an invalid app ID; no changes were made.") + continue matches.append(index) appids.append(appid.value) for index in matches: diff --git a/tests/test_uninstall.py b/tests/test_uninstall.py index 183011a..d96c810 100644 --- a/tests/test_uninstall.py +++ b/tests/test_uninstall.py @@ -124,6 +124,49 @@ def test_uninstall_keeps_saves_removes_all_exact_native_entries_and_managed_art( assert (saved / "save/profile.sav").read_bytes() == b"campaign progress" +def test_uninstall_removes_a_native_entry_the_user_renamed(installation): + remote, steam, configs, appids, _ = installation + config = configs[0] + executable = str(remote.GAME / "halo") + root = steam.loads((config / "shortcuts.vdf").read_bytes()) + for entry in root["shortcuts"].value.values(): + path = entry.value.get("Exe") + if path is not None and path.value in (executable, f'"{executable}"'): + entry.value["AppName"] = steam.Value(1, "My favorite Halo") + (config / "shortcuts.vdf").write_bytes(steam.dumps(root)) + result = remote.uninstall(RUN_ID, keep_saves=True) + assert result["uninstalled"] and result["steam"]["status"] == "removed" + assert result["steam"]["accounts"] == ["10", "20"] + remaining = steam.loads((config / "shortcuts.vdf").read_bytes())["shortcuts"].value + assert set(remaining) == {"0", "2"} + assert "My favorite Halo" not in {entry.value["AppName"].value for entry in remaining.values()} + assert result["steam"]["appids"] == sorted(set(appids)) + assert not (config / "grid" / f"{appids[0]}p.jpg").exists() + assert not (config / "grid" / f"{appids[0]}.png").exists() + assert (configs[1] / "grid" / f"{appids[1]}.png").read_bytes() == b"custom user tile" + assert (configs[0] / "grid/10p.jpg").read_bytes() == b"other game's art" + backups = list(config.glob("shortcuts.vdf.halo-frame-uninstall-*.bak")) + assert len(backups) == 1 and b"My favorite Halo" in backups[0].read_bytes() + + +def test_uninstall_skips_native_entries_with_malformed_ids_and_odd_quoting(installation): + remote, steam, configs, _, _ = installation + config = configs[0] + executable = str(remote.GAME / "halo") + root = steam.loads((config / "shortcuts.vdf").read_bytes()) + root["shortcuts"].value["3"] = steam.Value(0, OrderedDict([ + ("appid", steam.Value(1, "not-a-number")), ("AppName", steam.Value(1, "Broken id")), + ("Exe", steam.Value(1, executable))])) + root["shortcuts"].value["4"] = steam.Value(0, OrderedDict([ + ("appid", steam.Value(2, 0xF2233445)), ("AppName", steam.Value(1, "Odd quotes")), + ("Exe", steam.Value(1, executable + '"'))])) + (config / "shortcuts.vdf").write_bytes(steam.dumps(root)) + result = remote.uninstall(RUN_ID, keep_saves=True) + assert result["uninstalled"] and result["steam"]["status"] == "removed" + remaining = steam.loads((config / "shortcuts.vdf").read_bytes())["shortcuts"].value + assert set(remaining) == {"0", "2", "3", "4"} + + def test_uninstall_without_keep_saves_deletes_contained_saves_only(installation): remote, _, _, _, _ = installation outside = remote.HOME / "external-saves"