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
This commit is contained in:
MaxsTechReview authored and GitHub committed 2026-11-10 07:09:56 -05:00
1 parent 13c8edb3d5
commit 634cb390c9
6 files changed
+117 -5

No files matched your search

@@ -48,11 +48,19 @@ object ProtonExtras {
internal fun installedIn(dir: File, tool: Tool): List<String> =
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+")
@@ -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) {
@@ -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<String>(), 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"))
@@ -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:
@@ -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
+77
View File
@@ -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))