From 634cb390c9ee6df97578359634bb9facf8bcaf43 Mon Sep 17 00:00:00 2001 From: MaxsTechReview Date: Tue, 10 Nov 2026 07:09:56 -0500 Subject: [PATCH] fix(proton-run): register the wrapper for an installed Valve Proton depot (#2392) * fix(proton-run): find Proton tools in Steam libraries * fix(proton-run): register the wrapper for an installed Valve Proton depot Valve's ARM64 Proton depots are not compatibility tools, and running them directly bypasses the DroidDeck wrapper. Register the wrapper from the depot when a store app is launched before Steam has started, instead of scanning steamapps/common. * test(proton): install complete tool fixtures in the auto-update tests * fix(proton-run): skip incomplete Proton depots and keep listing tools when the wrapper cannot be written * fix(proton-run): pick a runnable depot and rebuild a wrapper whose launcher is missing * test(proton-run): remove redundant depot fixture write * fix(proton-run): rebuild a wrapper whose manifest is missing * test(proton-run): cover runnable depot selection --- .../launcher/session/ProtonExtras.kt | 10 ++- .../droiddeck/launcher/ProtonMenuAutoTest.kt | 4 +- .../launcher/session/ProtonExtrasTest.kt | 13 ++++ .../usr/local/bin/droiddeck-proton-run | 13 +++- .../overlay/usr/local/bin/steam-compatibility | 5 +- tools/tests/test_desktop_open.py | 77 +++++++++++++++++++ 6 files changed, 117 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt b/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt index 962291d2c..d17d40df4 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/ProtonExtras.kt @@ -48,11 +48,19 @@ object ProtonExtras { internal fun installedIn(dir: File, tool: Tool): List = dir.listFiles() - ?.filter { it.isDirectory && it.name.startsWith(tool.prefix) && File(it, "toolmanifest.vdf").isFile } + ?.filter { it.isDirectory && it.name.startsWith(tool.prefix) && usable(it) } ?.map { it.name } ?.sortedWith { a, b -> compareVersions(a, b) } ?: emptyList() + private fun usable(dir: File): Boolean { + val manifest = runCatching { File(dir, "toolmanifest.vdf").readText() }.getOrNull() ?: return false + val command = Regex("\\\"commandline\\\"\\s+\\\"/?([^\\\" ]+)").find(manifest)?.groupValues?.getOrNull(1) ?: return false + val compat = runCatching { File(dir, "compatibilitytool.vdf").readText() }.getOrNull() ?: return false + return Regex("\\\"from_oslist\\\"\\s+\\\"[^\\\"]*windows", RegexOption.IGNORE_CASE).containsMatchIn(compat) && + File(dir, command).isFile && File(dir, command).canExecute() + } + private val ARCH = Regex("(?i)aarch64|arm64|x86_64|x86-64|amd64") private val NUMBER = Regex("\\d+") diff --git a/app/src/test/java/com/droiddeck/launcher/ProtonMenuAutoTest.kt b/app/src/test/java/com/droiddeck/launcher/ProtonMenuAutoTest.kt index 7aff48691..14dbdb833 100644 --- a/app/src/test/java/com/droiddeck/launcher/ProtonMenuAutoTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ProtonMenuAutoTest.kt @@ -83,7 +83,9 @@ class ProtonMenuAutoTest { private fun install(name: String): File = File(root, "root/.local/share/Steam/compatibilitytools.d/$name").apply { mkdirs() - File(this, "toolmanifest.vdf").writeText("manifest") + File(this, "toolmanifest.vdf").writeText("\"manifest\" { \"commandline\" \"/proton %verb%\" }") + File(this, "compatibilitytool.vdf").writeText("\"from_oslist\" \"windows\"") + File(this, "proton").apply { writeText(""); setExecutable(true) } } private fun waitUntil(done: () -> Boolean) { diff --git a/app/src/test/java/com/droiddeck/launcher/session/ProtonExtrasTest.kt b/app/src/test/java/com/droiddeck/launcher/session/ProtonExtrasTest.kt index 411268ef6..43571a3ff 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/ProtonExtrasTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/ProtonExtrasTest.kt @@ -44,6 +44,8 @@ class ProtonExtrasTest { private fun installTool(parent: File, name: String) = File(parent, name).apply { mkdirs() File(this, "toolmanifest.vdf").writeText("\"manifest\" { \"commandline\" \"/proton %verb%\" }") + File(this, "compatibilitytool.vdf").writeText("\"from_oslist\" \"windows\"") + File(this, "proton").apply { writeText(""); setExecutable(true) } } @Test @@ -120,6 +122,17 @@ class ProtonExtrasTest { assertEquals(emptyList(), ProtonExtras.installedIn(File(tools, "missing"), tool("ge"))) } + @Test + fun interruptedBuildsAreNotReportedAsInstalled() { + val tools = temp.newFolder("compatibilitytools.d") + installTool(tools, "GE-Proton11-10") + File(tools, "GE-Proton11-11").apply { + mkdirs() + File(this, "toolmanifest.vdf").writeText("\"manifest\" { \"commandline\" \"/proton %verb%\" }") + } + assertEquals(listOf("GE-Proton11-10"), ProtonExtras.installedIn(tools, tool("ge"))) + } + @Test fun anUpdateIsANewerReleaseThanTheNewestInstalledBuild() { assertEquals("DroidDeck-Proton-11-7.6", ProtonExtras.updateFor(listOf("DroidDeck-Proton 11-7.5"), "DroidDeck-Proton-11-7.6")) diff --git a/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-run b/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-run index 3d2b6ec6f..164dbd028 100644 --- a/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-run +++ b/tools/linuxfs/overlay/usr/local/bin/droiddeck-proton-run @@ -70,8 +70,19 @@ def mapping(root): def tools(root): + c = compat() + wrapper = os.path.join(root, "compatibilitytools.d", c["TOOL"]) + complete = os.access(os.path.join(wrapper, c["LAUNCHER"]), os.X_OK) and os.path.isfile(os.path.join(wrapper, "toolmanifest.vdf")) + if c["TOOL"].lower() not in c["local_tools"](root) or not complete: + depot = c["find_source"](root, runnable=True) + if depot is not None: + try: + c["build_tool"](wrapper, depot) + except OSError: + pass found = {} - for base in [os.path.join(root, "compatibilitytools.d")] + list(SYSTEM_TOOLS): + bases = [os.path.join(root, "compatibilitytools.d")] + list(SYSTEM_TOOLS) + for base in bases: try: entries = sorted(os.listdir(base)) except OSError: diff --git a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility index 5695bd0b1..4a2cbcb15 100755 --- a/tools/linuxfs/overlay/usr/local/bin/steam-compatibility +++ b/tools/linuxfs/overlay/usr/local/bin/steam-compatibility @@ -1599,10 +1599,11 @@ def depot_build(depot): return int(first[0]) if first and first[0].isdigit() else 0 -def find_source(steam_root, sources=SOURCES): +def find_source(steam_root, sources=SOURCES, runnable=False): for name in sources: found = [os.path.join(root, "steamapps", "common", name) for root in libraries(steam_root) - if os.path.isdir(os.path.join(root, "steamapps", "common", name, "files", "bin-arm64"))] + if os.path.isdir(os.path.join(root, "steamapps", "common", name, "files", "bin-arm64")) + and (not runnable or os.access(os.path.join(root, "steamapps", "common", name, "proton"), os.X_OK))] if found: return max(found, key=depot_build) return None diff --git a/tools/tests/test_desktop_open.py b/tools/tests/test_desktop_open.py index a23d85544..c32baf71a 100644 --- a/tools/tests/test_desktop_open.py +++ b/tools/tests/test_desktop_open.py @@ -482,6 +482,83 @@ class ProtonRunTest(Temp): self.assertEqual(found["droiddeck-proton-arm64"], (str(self.root / "compatibilitytools.d/droiddeck-proton-arm64"), "droiddeck-proton")) + def test_a_valve_depot_is_registered_through_the_droiddeck_wrapper(self): + name = "Proton Experimental (ARM64)" + folder = "steamapps/common/%s" % name + shutil.rmtree(self.root / "compatibilitytools.d/droiddeck-proton-arm64") + self.write("Steam/steamapps/appmanifest_4427310.acf", '"AppState" { "appid" "4427310" }') + self.write("Steam/%s/toolmanifest.vdf" % folder, '"manifest"\n{\n "commandline" "/proton %verb%"\n}\n') + self.write("Steam/%s/proton" % folder, b"#!/bin/sh\n", 0o755) + self.write("Steam/%s/files/bin-arm64/proton" % folder, b"#!/bin/sh\n", 0o755) + found = RUN["tools"](str(self.root)) + wrapper = self.root / "compatibilitytools.d/droiddeck-proton-arm64" + self.assertEqual(found["droiddeck-proton-arm64"], (str(wrapper), "droiddeck-proton")) + self.assertNotIn(name, found) + + def test_an_incomplete_valve_depot_is_not_registered(self): + name = "Proton Experimental (ARM64)" + folder = "steamapps/common/%s" % name + shutil.rmtree(self.root / "compatibilitytools.d/droiddeck-proton-arm64") + self.write("Steam/steamapps/appmanifest_4427310.acf", '"AppState" { "appid" "4427310" }') + self.write("Steam/%s/toolmanifest.vdf" % folder, '"manifest"\n{\n "commandline" "/proton %verb%"\n}\n') + self.write("Steam/%s/files/bin-arm64/proton" % folder, b"#!/bin/sh\n", 0o755) + found = RUN["tools"](str(self.root)) + self.assertNotIn("droiddeck-proton-arm64", found) + self.assertFalse((self.root / "compatibilitytools.d/droiddeck-proton-arm64").exists()) + + def test_a_failed_wrapper_build_does_not_hide_existing_tools(self): + shutil.rmtree(self.root / "compatibilitytools.d/droiddeck-proton-arm64") + self.write("Steam/steamapps/appmanifest_4427310.acf", '"AppState" { "appid" "4427310" }') + folder = "steamapps/common/Proton Experimental (ARM64)" + self.write("Steam/%s/proton" % folder, b"#!/bin/sh\n", 0o755) + self.write("Steam/%s/files/bin-arm64/proton" % folder, b"#!/bin/sh\n", 0o755) + build = mock.Mock(side_effect=OSError("read-only")) + with mock.patch.dict(RUN["compat"](), {"build_tool": build}): + found = RUN["tools"](str(self.root)) + build.assert_called_once() + self.assertIn("droiddeck-proton-11-arm64", found) + + def test_a_runnable_depot_is_chosen_over_a_newer_incomplete_one(self): + shutil.rmtree(self.root / "compatibilitytools.d/droiddeck-proton-arm64") + self.write("Steam/steamapps/appmanifest_4427310.acf", '"AppState" { "appid" "4427310" }') + good = "Steam/steamapps/common/Proton Experimental (ARM64)" + self.write("%s/proton" % good, b"#!/bin/sh\n", 0o755) + self.write("%s/files/bin-arm64/proton" % good, b"#!/bin/sh\n", 0o755) + broken = "Library/steamapps/common/Proton Experimental (ARM64)" + self.write("Library/steamapps/libraryfolders.vdf", b"") + self.write("%s/version" % broken, b"2\n") + self.write("%s/files/bin-arm64/proton" % broken, b"#!/bin/sh\n", 0o755) + self.write("Steam/steamapps/libraryfolders.vdf", '"path" "%s"' % (self.tmp / "Library")) + compat = RUN["compat"]() + source = compat["find_source"] + self.assertEqual(source(str(self.root), runnable=True), str(self.tmp / good)) + + def test_a_registered_wrapper_without_its_launcher_is_rebuilt(self): + wrapper = self.root / "compatibilitytools.d/droiddeck-proton-arm64" + self.write("Steam/steamapps/appmanifest_4427310.acf", '"AppState" { "appid" "4427310" }') + folder = "Steam/steamapps/common/Proton Experimental (ARM64)" + self.write("%s/proton" % folder, b"#!/bin/sh\n", 0o755) + self.write("%s/files/bin-arm64/proton" % folder, b"#!/bin/sh\n", 0o755) + launcher = wrapper / RUN["compat"]()["LAUNCHER"] + if launcher.exists(): + launcher.unlink() + found = RUN["tools"](str(self.root)) + self.assertTrue(os.access(launcher, os.X_OK)) + self.assertIn("droiddeck-proton-arm64", found) + + def test_a_registered_wrapper_without_its_manifest_is_rebuilt(self): + wrapper = self.root / "compatibilitytools.d/droiddeck-proton-arm64" + self.write("Steam/steamapps/appmanifest_4427310.acf", '"AppState" { "appid" "4427310" }') + folder = "Steam/steamapps/common/Proton Experimental (ARM64)" + self.write("%s/proton" % folder, b"#!/bin/sh\n", 0o755) + self.write("%s/files/bin-arm64/proton" % folder, b"#!/bin/sh\n", 0o755) + manifest = wrapper / "toolmanifest.vdf" + if manifest.exists(): + manifest.unlink() + found = RUN["tools"](str(self.root)) + self.assertTrue(manifest.is_file()) + self.assertIn("droiddeck-proton-arm64", found) + def test_steams_choice_for_the_title_then_its_default(self): available = RUN["tools"](str(self.root)) current = RUN["mapping"](str(self.root))