From da125e1852126b4fc94e63fce984ec3839ef6b50 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 20 May 2026 09:25:54 +0200 Subject: [PATCH 01/11] feat: add arguments field to LaunchInfo and parse from PICS VDF --- .../java/app/gamenative/data/LaunchInfo.kt | 1 + .../app/gamenative/utils/KeyValueUtils.kt | 1 + .../data/LaunchInfoSerializationTest.kt | 90 +++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 app/src/test/java/app/gamenative/data/LaunchInfoSerializationTest.kt diff --git a/app/src/main/java/app/gamenative/data/LaunchInfo.kt b/app/src/main/java/app/gamenative/data/LaunchInfo.kt index 6ffd10068c..00f5307b4b 100644 --- a/app/src/main/java/app/gamenative/data/LaunchInfo.kt +++ b/app/src/main/java/app/gamenative/data/LaunchInfo.kt @@ -9,6 +9,7 @@ import kotlinx.serialization.Serializable @Serializable data class LaunchInfo( val executable: String, + val arguments: String = "", val workingDir: String, val description: String, val type: String, diff --git a/app/src/main/java/app/gamenative/utils/KeyValueUtils.kt b/app/src/main/java/app/gamenative/utils/KeyValueUtils.kt index b8b24c929f..1ec0de97f8 100644 --- a/app/src/main/java/app/gamenative/utils/KeyValueUtils.kt +++ b/app/src/main/java/app/gamenative/utils/KeyValueUtils.kt @@ -140,6 +140,7 @@ fun KeyValue.generateSteamApp(): SteamApp { launch = this["config"]["launch"].children.map { LaunchInfo( executable = it["executable"].value?.replace('\\', '/').orEmpty(), + arguments = it["arguments"].value.orEmpty(), workingDir = it["workingdir"].value?.replace('\\', '/').orEmpty(), description = it["description"].value.orEmpty(), type = it["type"].value.orEmpty(), diff --git a/app/src/test/java/app/gamenative/data/LaunchInfoSerializationTest.kt b/app/src/test/java/app/gamenative/data/LaunchInfoSerializationTest.kt new file mode 100644 index 0000000000..0b1400e8fd --- /dev/null +++ b/app/src/test/java/app/gamenative/data/LaunchInfoSerializationTest.kt @@ -0,0 +1,90 @@ +package app.gamenative.data + +import app.gamenative.enums.OS +import app.gamenative.enums.OSArch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Test +import java.util.EnumSet + +class LaunchInfoSerializationTest { + + private val json = Json { ignoreUnknownKeys = true } + + @Test + fun roundTrip_preservesArguments() { + val info = LaunchInfo( + executable = "hl2.exe", + arguments = "-game portal", + workingDir = "", + description = "Play Portal", + type = "default", + configOS = EnumSet.of(OS.windows), + configArch = OSArch.Arch64, + ) + + val encoded = json.encodeToString(info) + val decoded = json.decodeFromString(encoded) + + assertEquals("-game portal", decoded.arguments) + assertEquals("hl2.exe", decoded.executable) + assertEquals("Play Portal", decoded.description) + } + + @Test + fun deserialize_defaultsArgumentsToEmpty_whenFieldMissing() { + // configOS is serialized as an int bitmask (windows = 0x01 = 1), configArch as a string name + val jsonString = """ + { + "executable": "game.exe", + "workingDir": "", + "description": "", + "type": "default", + "configOS": 1, + "configArch": "Arch64" + } + """.trimIndent() + + val decoded = json.decodeFromString(jsonString) + + assertEquals("", decoded.arguments) + assertEquals("game.exe", decoded.executable) + } + + @Test + fun roundTrip_handlesEmptyArguments() { + val info = LaunchInfo( + executable = "game.exe", + arguments = "", + workingDir = "bin", + description = "", + type = "none", + configOS = EnumSet.of(OS.windows), + configArch = OSArch.Arch32, + ) + + val encoded = json.encodeToString(info) + val decoded = json.decodeFromString(encoded) + + assertEquals("", decoded.arguments) + } + + @Test + fun roundTrip_preservesUriExecutable() { + val info = LaunchInfo( + executable = "link2ea://launchgame/Origin.OFR.50.0002694", + arguments = "", + workingDir = "", + description = "Play Game", + type = "default", + configOS = EnumSet.of(OS.windows), + configArch = OSArch.Arch64, + ) + + val encoded = json.encodeToString(info) + val decoded = json.decodeFromString(encoded) + + assertEquals("link2ea://launchgame/Origin.OFR.50.0002694", decoded.executable) + } +} From 00e373a7256eec41732cc69461e455fe47e8a987 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 20 May 2026 09:29:45 +0200 Subject: [PATCH 02/11] feat: add URI/batch detection utilities and expand scanner to .bat/.cmd --- .../app/gamenative/utils/ContainerUtils.kt | 26 ++--- .../utils/ContainerUtilsExecutableTest.kt | 98 +++++++++++++++++++ 2 files changed, 111 insertions(+), 13 deletions(-) create mode 100644 app/src/test/java/app/gamenative/utils/ContainerUtilsExecutableTest.kt diff --git a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt index 3e874958d2..f78a20a60f 100644 --- a/app/src/main/java/app/gamenative/utils/ContainerUtils.kt +++ b/app/src/main/java/app/gamenative/utils/ContainerUtils.kt @@ -1182,6 +1182,11 @@ object ContainerUtils { return null } + fun isUriScheme(path: String): Boolean = path.contains("://") + + fun isBatchScript(path: String): Boolean = + path.endsWith(".bat", ignoreCase = true) || path.endsWith(".cmd", ignoreCase = true) + /** * Scans the container's A: drive for all .exe files */ @@ -1213,7 +1218,9 @@ object ContainerUtils { if (file.isDirectory) { if (FileUtils.isSymlink(file)) return@forEach scanRecursive(file, baseDir, depth + 1, maxDepth) - } else if (file.isFile && file.name.lowercase().endsWith(".exe")) { + } else if (file.isFile && file.name.lowercase().let { name -> + name.endsWith(".exe") || name.endsWith(".bat") || name.endsWith(".cmd") + }) { // Convert to relative Windows path format val relativePath = baseDir.toURI().relativize(file.toURI()).path executables.add(relativePath) @@ -1256,28 +1263,21 @@ object ContainerUtils { * Assigns priority scores to executables for better sorting */ private fun getExecutablePriority(exePath: String): Int { - val fileName = exePath.substringAfterLast('\\').lowercase() + val fileName = exePath.substringAfterLast('\\').substringAfterLast('/').lowercase() val baseName = fileName.substringBeforeLast('.') + val isBatch = fileName.endsWith(".bat") || fileName.endsWith(".cmd") - return when { - // Highest priority: common game executable patterns + val baseScore = when { fileName.contains("game") -> 100 - fileName.contains("start") -> 85 - fileName.contains("main") -> 80 - fileName.contains("launcher") && !fileName.contains("unins") -> 75 - - // High priority: probable main executables baseName.length >= 4 && !isSystemExecutable(fileName) -> 70 - - // Medium priority: any non-system executable !isSystemExecutable(fileName) -> 50 - - // Low priority: system/utility executables else -> 10 } + + return if (isBatch) (baseScore - 10).coerceAtLeast(5) else baseScore } /** diff --git a/app/src/test/java/app/gamenative/utils/ContainerUtilsExecutableTest.kt b/app/src/test/java/app/gamenative/utils/ContainerUtilsExecutableTest.kt new file mode 100644 index 0000000000..2e12f16fc3 --- /dev/null +++ b/app/src/test/java/app/gamenative/utils/ContainerUtilsExecutableTest.kt @@ -0,0 +1,98 @@ +package app.gamenative.utils + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ContainerUtilsExecutableTest { + + @Test + fun isUriScheme_returnsTrueForLink2ea() { + assertTrue(ContainerUtils.isUriScheme("link2ea://launchgame/Origin.OFR.50.0002694")) + } + + @Test + fun isUriScheme_returnsTrueForSteamProtocol() { + assertTrue(ContainerUtils.isUriScheme("steam://rungameid/12345")) + } + + @Test + fun isUriScheme_returnsFalseForExe() { + assertFalse(ContainerUtils.isUriScheme("game.exe")) + } + + @Test + fun isUriScheme_returnsFalseForWindowsPath() { + assertFalse(ContainerUtils.isUriScheme("C:\\Program Files\\game.exe")) + } + + @Test + fun isUriScheme_returnsFalseForEmpty() { + assertFalse(ContainerUtils.isUriScheme("")) + } + + @Test + fun isBatchScript_returnsTrueForBat() { + assertTrue(ContainerUtils.isBatchScript("launch.bat")) + } + + @Test + fun isBatchScript_returnsTrueForCmd() { + assertTrue(ContainerUtils.isBatchScript("start.cmd")) + } + + @Test + fun isBatchScript_isCaseInsensitive() { + assertTrue(ContainerUtils.isBatchScript("LAUNCH.BAT")) + assertTrue(ContainerUtils.isBatchScript("Start.Cmd")) + } + + @Test + fun isBatchScript_returnsFalseForExe() { + assertFalse(ContainerUtils.isBatchScript("game.exe")) + } + + @Test + fun isBatchScript_returnsFalseForEmpty() { + assertFalse(ContainerUtils.isBatchScript("")) + } + + @Test + fun scanExecutablesInADrive_findsBatAndCmdFiles() { + val tempDir = kotlin.io.path.createTempDirectory("scan-test").toFile() + try { + val drives = "A:${tempDir.absolutePath}" + java.io.File(tempDir, "game.exe").createNewFile() + java.io.File(tempDir, "launcher.bat").createNewFile() + java.io.File(tempDir, "setup.cmd").createNewFile() + java.io.File(tempDir, "readme.txt").createNewFile() + + val results = ContainerUtils.scanExecutablesInADrive(drives) + + assertTrue("Should find game.exe", results.any { it.endsWith("game.exe") }) + assertTrue("Should find launcher.bat", results.any { it.endsWith("launcher.bat") }) + assertTrue("Should find setup.cmd", results.any { it.endsWith("setup.cmd") }) + assertFalse("Should not find readme.txt", results.any { it.endsWith("readme.txt") }) + } finally { + tempDir.deleteRecursively() + } + } + + @Test + fun scanExecutablesInADrive_exeRanksHigherThanBatCmd() { + val tempDir = kotlin.io.path.createTempDirectory("scan-priority-test").toFile() + try { + val drives = "A:${tempDir.absolutePath}" + java.io.File(tempDir, "game.exe").createNewFile() + java.io.File(tempDir, "game.bat").createNewFile() + + val results = ContainerUtils.scanExecutablesInADrive(drives) + + val exeIndex = results.indexOfFirst { it.endsWith("game.exe") } + val batIndex = results.indexOfFirst { it.endsWith("game.bat") } + assertTrue("game.exe should rank before game.bat", exeIndex < batIndex) + } finally { + tempDir.deleteRecursively() + } + } +} From 8f265f18fc09a94465912668dcbbcbf7724d89f2 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 20 May 2026 09:31:30 +0200 Subject: [PATCH 03/11] feat: expand PICS launch filter to accept .bat, .cmd, and URI schemes --- app/src/main/java/app/gamenative/service/SteamService.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/gamenative/service/SteamService.kt b/app/src/main/java/app/gamenative/service/SteamService.kt index 5c503bca98..c5e72428ad 100644 --- a/app/src/main/java/app/gamenative/service/SteamService.kt +++ b/app/src/main/java/app/gamenative/service/SteamService.kt @@ -2269,8 +2269,11 @@ class SteamService : Service(), IChallengeUrlChanged { fun getWindowsLaunchInfos(appId: Int): List { return getAppInfoOf(appId)?.let { appInfo -> appInfo.config.launch.filter { launchInfo -> - // since configOS was unreliable and configArch was even more unreliable - launchInfo.executable.endsWith(".exe", ignoreCase = true) + val exe = launchInfo.executable + exe.endsWith(".exe", ignoreCase = true) || + exe.endsWith(".bat", ignoreCase = true) || + exe.endsWith(".cmd", ignoreCase = true) || + exe.contains("://") } }.orEmpty() } From f530cfb8f7d53aaaa047e87cb3174b58d4dbc0c0 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 20 May 2026 09:33:38 +0200 Subject: [PATCH 04/11] feat: thread steamAppId through dialog stack for PICS lookup --- app/src/main/java/app/gamenative/ui/PluviaMain.kt | 1 + .../app/gamenative/ui/component/dialog/ContainerConfigDialog.kt | 2 ++ .../app/gamenative/ui/component/dialog/ContainerConfigState.kt | 1 + .../app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt | 1 + 4 files changed, 5 insertions(+) diff --git a/app/src/main/java/app/gamenative/ui/PluviaMain.kt b/app/src/main/java/app/gamenative/ui/PluviaMain.kt index 446258fa9b..1be85d6c5a 100644 --- a/app/src/main/java/app/gamenative/ui/PluviaMain.kt +++ b/app/src/main/java/app/gamenative/ui/PluviaMain.kt @@ -1158,6 +1158,7 @@ fun PluviaMain( visible = true, title = context.getString(R.string.container_config_title), initialConfig = config, + steamAppId = appId.removePrefix("STEAM_").toIntOrNull()?.takeIf { appId.startsWith("STEAM_") }, onDismissRequest = { openContainerConfigForAppId = null }, onSave = { newConfig -> scope.launch { diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt index bcf06bcc8a..71e6aabefa 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt @@ -143,6 +143,7 @@ fun ContainerConfigDialog( default: Boolean = false, title: String, initialConfig: ContainerData = ContainerData(), + steamAppId: Int? = null, onDismissRequest: () -> Unit, onSave: (ContainerData) -> Unit, ) { @@ -941,6 +942,7 @@ fun ContainerConfigDialog( val state = ContainerConfigState( config = configState, + steamAppId = steamAppId, graphicsDrivers = graphicsDriversRef, bionicWineEntries = bionicWineEntriesRef, glibcWineEntries = glibcWineEntriesRef, diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigState.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigState.kt index 533468ff12..b095ade333 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigState.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigState.kt @@ -16,6 +16,7 @@ import com.winlator.fexcore.FEXCorePreset */ class ContainerConfigState( val config: MutableState, + val steamAppId: Int? = null, val graphicsDrivers: MutableState>, val bionicWineEntries: MutableState>, val glibcWineEntries: MutableState>, diff --git a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt index 5510869c31..1af25c9a1e 100644 --- a/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/library/appscreen/BaseAppScreen.kt @@ -1221,6 +1221,7 @@ abstract class BaseAppScreen { ContainerConfigDialog( title = "${displayInfo.name} Config", initialConfig = containerData, + steamAppId = if (libraryItem.gameSource == GameSource.STEAM) libraryItem.gameId else null, onDismissRequest = { showConfigDialog = false }, onSave = { saveContainerConfig(context, libraryItem, it) From 63a23a9c3219d99f3e7df9244b4ddc3e5f38ba71 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 20 May 2026 09:36:41 +0200 Subject: [PATCH 05/11] feat: rewrite ExecutablePathDropdown with PICS entries and auto-default --- .../component/dialog/ContainerConfigDialog.kt | 90 ++++++++++++++----- .../ui/component/dialog/GeneralTab.kt | 8 +- 2 files changed, 76 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt index 71e6aabefa..db6682a43e 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt @@ -36,6 +36,7 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExposedDropdownMenuBox import androidx.compose.material3.ExposedDropdownMenuDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -83,6 +84,7 @@ import app.gamenative.ui.theme.PluviaTheme import app.gamenative.ui.theme.settingsTileColors import app.gamenative.ui.theme.settingsTileColorsAlt import app.gamenative.utils.CustomGameScanner +import app.gamenative.data.LaunchInfo import app.gamenative.utils.ContainerUtils import app.gamenative.utils.ManifestComponentHelper import app.gamenative.utils.ManifestContentTypes @@ -1232,39 +1234,50 @@ private fun Preview_ContainerConfigDialog() { } } -/** - * Editable dropdown for selecting executable paths from the container's A: drive - */ @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun ExecutablePathDropdown( modifier: Modifier = Modifier, value: String, - onValueChange: (String) -> Unit, + onLaunchOptionSelected: (executablePath: String, execArgs: String?) -> Unit, containerData: ContainerData, + steamAppId: Int? = null, ) { var expanded by remember { mutableStateOf(false) } var executables by remember { mutableStateOf>(emptyList()) } + var picsEntries by remember { mutableStateOf>(emptyList()) } var isLoading by remember { mutableStateOf(true) } - val context = LocalContext.current - // Load executables from A: drive when component is first created - LaunchedEffect(containerData.drives) { + LaunchedEffect(containerData.drives, steamAppId) { isLoading = true - executables = withContext(Dispatchers.IO) { - ContainerUtils.scanExecutablesInADrive(containerData.drives) + val (pics, scanned) = withContext(Dispatchers.IO) { + val pics = if (steamAppId != null) { + SteamService.getWindowsLaunchInfos(steamAppId) + } else { + emptyList() + } + val scanned = ContainerUtils.scanExecutablesInADrive(containerData.drives) + pics to scanned + } + picsEntries = pics + executables = scanned + + if (value.isEmpty() && pics.isNotEmpty()) { + val defaultEntry = pics.firstOrNull { it.type == "default" } ?: pics.first() + onLaunchOptionSelected(defaultEntry.executable, defaultEntry.arguments) } + isLoading = false } ExposedDropdownMenuBox( expanded = expanded, onExpandedChange = { expanded = it }, - modifier = modifier + modifier = modifier, ) { NoExtractOutlinedTextField( value = value, - onValueChange = onValueChange, + onValueChange = { }, readOnly = true, label = { Text(stringResource(R.string.container_config_executable_path)) }, placeholder = { Text(stringResource(R.string.container_config_executable_path_placeholder)) }, @@ -1278,35 +1291,70 @@ internal fun ExecutablePathDropdown( modifier = Modifier .fillMaxWidth() .menuAnchor(), - singleLine = true + singleLine = true, ) - if (!isLoading && executables.isNotEmpty()) { + val hasContent = !isLoading && (picsEntries.isNotEmpty() || executables.isNotEmpty()) + if (hasContent) { ExposedDropdownMenu( expanded = expanded, - onDismissRequest = { expanded = false } + onDismissRequest = { expanded = false }, ) { + picsEntries.forEach { entry -> + DropdownMenuItem( + text = { + Column { + Text( + text = entry.description.ifEmpty { "Default" }, + style = MaterialTheme.typography.bodyMedium, + ) + Text( + text = entry.executable, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (entry.arguments.isNotEmpty()) { + Text( + text = entry.arguments, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + onClick = { + onLaunchOptionSelected(entry.executable, entry.arguments) + expanded = false + }, + ) + } + + if (picsEntries.isNotEmpty() && executables.isNotEmpty()) { + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + } + executables.forEach { executable -> DropdownMenuItem( text = { Column { Text( - text = executable.substringAfterLast('\\'), - style = MaterialTheme.typography.bodyMedium + text = executable.substringAfterLast('/').substringAfterLast('\\'), + style = MaterialTheme.typography.bodyMedium, ) - if (executable.contains('\\')) { + val parent = executable.substringBeforeLast('/').substringBeforeLast('\\') + if (parent != executable) { Text( - text = executable.substringBeforeLast('\\'), + text = parent, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } }, onClick = { - onValueChange(executable) + onLaunchOptionSelected(executable, null) expanded = false - } + }, ) } } diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt b/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt index 0ede7ebef2..1f2beee96f 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/GeneralTab.kt @@ -259,8 +259,14 @@ fun GeneralTabContent( ExecutablePathDropdown( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), value = config.executablePath, - onValueChange = { state.config.value = config.copy(executablePath = it) }, + onLaunchOptionSelected = { path, args -> + state.config.value = config.copy( + executablePath = path, + execArgs = args ?: config.execArgs, + ) + }, containerData = config, + steamAppId = state.steamAppId, ) NoExtractOutlinedTextField( modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), From 4e135e481be1f75a2a9c9cbf82070ce516e5d0c8 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 20 May 2026 09:39:16 +0200 Subject: [PATCH 06/11] feat: handle URI schemes and batch scripts in launch pipeline --- .../gamenative/ui/screen/xserver/XServerScreen.kt | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index d21f5d4240..1c95e0edb6 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -265,6 +265,7 @@ private fun normalizeProcessName(name: String): String { private fun extractExecutableBasename(path: String): String { if (path.isBlank()) return "" + if (ContainerUtils.isUriScheme(path)) return "" return normalizeProcessName(path) } @@ -1689,7 +1690,7 @@ fun XServerScreen( if (!bootToContainer) { renderer.setUnviewableWMClasses("explorer.exe") // TODO: make 'force fullscreen' be an option of the app being launched - if (container.executablePath.isNotBlank()) { + if (container.executablePath.isNotBlank() && !ContainerUtils.isUriScheme(container.executablePath)) { renderer.forceFullscreenWMClass = Paths.get(container.executablePath).name } } @@ -3723,7 +3724,11 @@ private fun getWineStartCommand( // Normalize path separators (ensure Windows-style backslashes) val normalizedPath = executablePath.replace('/', '\\') envVars.put("WINEPATH", "A:\\") - "\"A:\\${normalizedPath}\"" + when { + ContainerUtils.isUriScheme(executablePath) -> "start $executablePath" + ContainerUtils.isBatchScript(executablePath) -> "cmd /c \"A:\\${normalizedPath}\"" + else -> "\"A:\\${normalizedPath}\"" + } } else if (container.executablePath.isEmpty()) { // For Steam games, we need appLaunchInfo Timber.tag("XServerScreen").w("appLaunchInfo is null for Steam game: $appId") @@ -3773,7 +3778,11 @@ private fun getWineStartCommand( if (appLaunchInfo != null){ envVars.put("WINEPATH", "$drive:/${appLaunchInfo.workingDir}") } - "\"$drive:/${executablePath}\"" + when { + ContainerUtils.isUriScheme(executablePath) -> "start $executablePath" + ContainerUtils.isBatchScript(executablePath) -> "cmd /c \"$drive:/${executablePath}\"" + else -> "\"$drive:/${executablePath}\"" + } } else { "\"C:\\\\Program Files (x86)\\\\Steam\\\\steamclient_loader_x64.exe\"" } From 0bc89cfebcd61ef13e97ca7f5d80b2413f923a20 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 20 May 2026 09:40:21 +0200 Subject: [PATCH 07/11] refactor: remove Portal LaunchArgFix, PICS provides -game portal args --- .../app/gamenative/gamefixes/GameFixesRegistry.kt | 1 - .../main/java/app/gamenative/gamefixes/STEAM_400.kt | 13 ------------- 2 files changed, 14 deletions(-) delete mode 100644 app/src/main/java/app/gamenative/gamefixes/STEAM_400.kt diff --git a/app/src/main/java/app/gamenative/gamefixes/GameFixesRegistry.kt b/app/src/main/java/app/gamenative/gamefixes/GameFixesRegistry.kt index 74f7475e9f..c8037c3033 100644 --- a/app/src/main/java/app/gamenative/gamefixes/GameFixesRegistry.kt +++ b/app/src/main/java/app/gamenative/gamefixes/GameFixesRegistry.kt @@ -29,7 +29,6 @@ object GameFixesRegistry { GOG_Fix_1787707874, GOG_Fix_1808582759, GOG_Fix_2147483047, - STEAM_Fix_400, STEAM_Fix_22300, STEAM_Fix_22330, STEAM_Fix_22370, diff --git a/app/src/main/java/app/gamenative/gamefixes/STEAM_400.kt b/app/src/main/java/app/gamenative/gamefixes/STEAM_400.kt deleted file mode 100644 index 15f9647529..0000000000 --- a/app/src/main/java/app/gamenative/gamefixes/STEAM_400.kt +++ /dev/null @@ -1,13 +0,0 @@ -package app.gamenative.gamefixes - -import app.gamenative.data.GameSource - -/** - * Portal (Steam) - */ -val STEAM_Fix_400: KeyedGameFix = KeyedLaunchArgFix( - gameSource = GameSource.STEAM, - gameId = "400", - launchArgs = "-game portal", -) - From cb37b72a582f8ed3fa1061f87b82af497c17eb36 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 20 May 2026 10:36:59 +0200 Subject: [PATCH 08/11] fix: default to PICS entry when execArgs is empty, not just when executablePath is empty --- .../app/gamenative/ui/component/dialog/ContainerConfigDialog.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt index db6682a43e..6011dcd594 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt @@ -1262,7 +1262,7 @@ internal fun ExecutablePathDropdown( picsEntries = pics executables = scanned - if (value.isEmpty() && pics.isNotEmpty()) { + if (pics.isNotEmpty() && containerData.execArgs.isEmpty()) { val defaultEntry = pics.firstOrNull { it.type == "default" } ?: pics.first() onLaunchOptionSelected(defaultEntry.executable, defaultEntry.arguments) } From f4caa02f72633f5b74e114173ecaf348c9326f22 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 20 May 2026 11:43:34 +0200 Subject: [PATCH 09/11] =?UTF-8?q?fix:=20address=20PR=20review=20=E2=80=94?= =?UTF-8?q?=20auto-default=20guard,=20URI=20quoting,=20test=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../component/dialog/ContainerConfigDialog.kt | 12 +++++++--- .../ui/screen/xserver/XServerScreen.kt | 24 ++++++++++++------- .../data/LaunchInfoSerializationTest.kt | 1 + .../utils/ContainerUtilsExecutableTest.kt | 2 ++ 4 files changed, 28 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt index 6011dcd594..c9d3eb51da 100644 --- a/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt +++ b/app/src/main/java/app/gamenative/ui/component/dialog/ContainerConfigDialog.kt @@ -1262,9 +1262,15 @@ internal fun ExecutablePathDropdown( picsEntries = pics executables = scanned - if (pics.isNotEmpty() && containerData.execArgs.isEmpty()) { - val defaultEntry = pics.firstOrNull { it.type == "default" } ?: pics.first() - onLaunchOptionSelected(defaultEntry.executable, defaultEntry.arguments) + if (pics.isNotEmpty()) { + val alreadyMatchesPics = pics.any { + it.executable == value && it.arguments == containerData.execArgs + } + val alreadyMatchesScanned = scanned.any { it == value } + if (!alreadyMatchesPics && !alreadyMatchesScanned) { + val defaultEntry = pics.firstOrNull { it.type == "default" } ?: pics.first() + onLaunchOptionSelected(defaultEntry.executable, defaultEntry.arguments) + } } isLoading = false diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index 1c95e0edb6..b98d1c4121 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -3717,15 +3717,19 @@ private fun getWineStartCommand( return "winhandler.exe \"wfm.exe\"" } - // Set working directory to the game folder - val executableDir = gameFolderPath + "/" + executablePath.substringBeforeLast("/", "") + // Set working directory to the game folder (URIs have no meaningful subdirectory) + val executableDir = if (ContainerUtils.isUriScheme(executablePath)) { + gameFolderPath!! + } else { + gameFolderPath + "/" + executablePath.substringBeforeLast("/", "") + } guestProgramLauncherComponent.workingDir = File(executableDir) // Normalize path separators (ensure Windows-style backslashes) val normalizedPath = executablePath.replace('/', '\\') envVars.put("WINEPATH", "A:\\") when { - ContainerUtils.isUriScheme(executablePath) -> "start $executablePath" + ContainerUtils.isUriScheme(executablePath) -> "cmd /c start \"\" \"$executablePath\"" ContainerUtils.isBatchScript(executablePath) -> "cmd /c \"A:\\${normalizedPath}\"" else -> "\"A:\\${normalizedPath}\"" } @@ -3761,11 +3765,15 @@ private fun getWineStartCommand( } if (container.isUseLegacyDRM) { val appDirPath = SteamService.getAppDirPath(gameId) - val executableDir = appDirPath + "/" + executablePath.substringBeforeLast("/", "") - guestProgramLauncherComponent.workingDir = File(executableDir); - Timber.i("Working directory is ${executableDir}") + val executableDir = if (ContainerUtils.isUriScheme(executablePath)) { + appDirPath + } else { + appDirPath + "/" + executablePath.substringBeforeLast("/", "") + } + guestProgramLauncherComponent.workingDir = File(executableDir) + Timber.i("Working directory is $executableDir") - Timber.i("Final exe path is " + executablePath) + Timber.i("Final exe path is $executablePath") val drives = container.drives val driveIndex = drives.indexOf(appDirPath) // greater than 1 since there is the drive character and the colon before the app dir path @@ -3779,7 +3787,7 @@ private fun getWineStartCommand( envVars.put("WINEPATH", "$drive:/${appLaunchInfo.workingDir}") } when { - ContainerUtils.isUriScheme(executablePath) -> "start $executablePath" + ContainerUtils.isUriScheme(executablePath) -> "cmd /c start \"\" \"$executablePath\"" ContainerUtils.isBatchScript(executablePath) -> "cmd /c \"$drive:/${executablePath}\"" else -> "\"$drive:/${executablePath}\"" } diff --git a/app/src/test/java/app/gamenative/data/LaunchInfoSerializationTest.kt b/app/src/test/java/app/gamenative/data/LaunchInfoSerializationTest.kt index 0b1400e8fd..ae55a9e99b 100644 --- a/app/src/test/java/app/gamenative/data/LaunchInfoSerializationTest.kt +++ b/app/src/test/java/app/gamenative/data/LaunchInfoSerializationTest.kt @@ -2,6 +2,7 @@ package app.gamenative.data import app.gamenative.enums.OS import app.gamenative.enums.OSArch +import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import org.junit.Assert.assertEquals diff --git a/app/src/test/java/app/gamenative/utils/ContainerUtilsExecutableTest.kt b/app/src/test/java/app/gamenative/utils/ContainerUtilsExecutableTest.kt index 2e12f16fc3..a216ecc3c1 100644 --- a/app/src/test/java/app/gamenative/utils/ContainerUtilsExecutableTest.kt +++ b/app/src/test/java/app/gamenative/utils/ContainerUtilsExecutableTest.kt @@ -90,6 +90,8 @@ class ContainerUtilsExecutableTest { val exeIndex = results.indexOfFirst { it.endsWith("game.exe") } val batIndex = results.indexOfFirst { it.endsWith("game.bat") } + assertTrue("game.exe should be found", exeIndex >= 0) + assertTrue("game.bat should be found", batIndex >= 0) assertTrue("game.exe should rank before game.bat", exeIndex < batIndex) } finally { tempDir.deleteRecursively() From 715866ce69b30f396996d062e8c98a351919fdd1 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 20 May 2026 12:06:25 +0200 Subject: [PATCH 10/11] fix: use start /WAIT for URI launches to prevent premature container shutdown --- .../java/app/gamenative/ui/screen/xserver/XServerScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index b98d1c4121..1b35342ce4 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -3729,7 +3729,7 @@ private fun getWineStartCommand( val normalizedPath = executablePath.replace('/', '\\') envVars.put("WINEPATH", "A:\\") when { - ContainerUtils.isUriScheme(executablePath) -> "cmd /c start \"\" \"$executablePath\"" + ContainerUtils.isUriScheme(executablePath) -> "cmd /c start /WAIT \"\" \"$executablePath\"" ContainerUtils.isBatchScript(executablePath) -> "cmd /c \"A:\\${normalizedPath}\"" else -> "\"A:\\${normalizedPath}\"" } @@ -3787,7 +3787,7 @@ private fun getWineStartCommand( envVars.put("WINEPATH", "$drive:/${appLaunchInfo.workingDir}") } when { - ContainerUtils.isUriScheme(executablePath) -> "cmd /c start \"\" \"$executablePath\"" + ContainerUtils.isUriScheme(executablePath) -> "cmd /c start /WAIT \"\" \"$executablePath\"" ContainerUtils.isBatchScript(executablePath) -> "cmd /c \"$drive:/${executablePath}\"" else -> "\"$drive:/${executablePath}\"" } From d22a1de4c2dde9df08dee2ed9153710ff74af53d Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 20 May 2026 12:13:43 +0200 Subject: [PATCH 11/11] fix: use cmd /c call for batch scripts to handle paths with spaces --- .../java/app/gamenative/ui/screen/xserver/XServerScreen.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index 1b35342ce4..1cabcc7fd1 100644 --- a/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt +++ b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt @@ -3730,7 +3730,7 @@ private fun getWineStartCommand( envVars.put("WINEPATH", "A:\\") when { ContainerUtils.isUriScheme(executablePath) -> "cmd /c start /WAIT \"\" \"$executablePath\"" - ContainerUtils.isBatchScript(executablePath) -> "cmd /c \"A:\\${normalizedPath}\"" + ContainerUtils.isBatchScript(executablePath) -> "cmd /c call \"A:\\${normalizedPath}\"" else -> "\"A:\\${normalizedPath}\"" } } else if (container.executablePath.isEmpty()) { @@ -3788,7 +3788,7 @@ private fun getWineStartCommand( } when { ContainerUtils.isUriScheme(executablePath) -> "cmd /c start /WAIT \"\" \"$executablePath\"" - ContainerUtils.isBatchScript(executablePath) -> "cmd /c \"$drive:/${executablePath}\"" + ContainerUtils.isBatchScript(executablePath) -> "cmd /c call \"$drive:/${executablePath}\"" else -> "\"$drive:/${executablePath}\"" } } else {