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))