From 7a72ffaf800a3e6480771af7dacb0349f563b974 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 20 May 2026 13:13:22 +0200 Subject: [PATCH 01/10] feat: add InstallScript data classes for VDF parsing --- .../utils/installscript/InstallScriptData.kt | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 app/src/main/java/app/gamenative/utils/installscript/InstallScriptData.kt diff --git a/app/src/main/java/app/gamenative/utils/installscript/InstallScriptData.kt b/app/src/main/java/app/gamenative/utils/installscript/InstallScriptData.kt new file mode 100644 index 0000000000..ced8ef9d73 --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/installscript/InstallScriptData.kt @@ -0,0 +1,34 @@ +package app.gamenative.utils.installscript + +data class RegistryValues( + val strings: Map = emptyMap(), + val dwords: Map = emptyMap(), +) + +data class RegistryAction( + val keyPath: String, + val values: RegistryValues, + val languageOverrides: Map = emptyMap(), +) + +data class OSRequirement( + val is64BitWindows: Boolean? = null, + val osType: String? = null, +) + +data class RunProcessAction( + val name: String, + val process: String, + val command: String = "", + val hasRunKey: String? = null, + val noCleanUp: Boolean = false, + val minimumHasRunValue: Int = 0, + val requirementOS: OSRequirement? = null, + val asCurrentUser: Boolean = false, +) + +data class InstallScript( + val sourcePath: String, + val registryActions: List = emptyList(), + val runProcessActions: List = emptyList(), +) From ba0d2d9268e3bfc75e65cc51db8c03688e0294a7 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 20 May 2026 13:14:58 +0200 Subject: [PATCH 02/10] feat: parse per-depot installscript from Steam app metadata --- app/src/main/java/app/gamenative/data/DepotInfo.kt | 1 + app/src/main/java/app/gamenative/utils/KeyValueUtils.kt | 1 + 2 files changed, 2 insertions(+) diff --git a/app/src/main/java/app/gamenative/data/DepotInfo.kt b/app/src/main/java/app/gamenative/data/DepotInfo.kt index 0d5084ade0..87ddc47f4b 100644 --- a/app/src/main/java/app/gamenative/data/DepotInfo.kt +++ b/app/src/main/java/app/gamenative/data/DepotInfo.kt @@ -26,6 +26,7 @@ data class DepotInfo( val realm: SteamRealm = SteamRealm.Unknown, val systemDefined: Boolean = false, val steamDeck: Boolean = false, + val installScript: String = "", ) { /** Windows or OS-untagged (neither Linux nor macOS) */ val isWindowsCompatible: Boolean diff --git a/app/src/main/java/app/gamenative/utils/KeyValueUtils.kt b/app/src/main/java/app/gamenative/utils/KeyValueUtils.kt index b8b24c929f..0781172c44 100644 --- a/app/src/main/java/app/gamenative/utils/KeyValueUtils.kt +++ b/app/src/main/java/app/gamenative/utils/KeyValueUtils.kt @@ -62,6 +62,7 @@ fun KeyValue.generateSteamApp(): SteamApp { systemDefined = currentDepot["systemdefined"].asBoolean(), optionalDlcId = currentDepot["config"]["optionaldlc"].asInteger(INVALID_APP_ID), steamDeck = currentDepot["config"]["steamdeck"].asBoolean(false), + installScript = currentDepot["installscript"].value.orEmpty(), ) }, branches = this["depots"]["branches"].children.associate { From dca0ec09789b10aef98b1273993406fc3cb33a7a Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 20 May 2026 13:20:41 +0200 Subject: [PATCH 03/10] feat: implement InstallScript VDF parser with env var expansion --- .../installscript/InstallScriptParser.kt | 220 +++++++++++++++++ .../installscript/InstallScriptParserTest.kt | 225 ++++++++++++++++++ 2 files changed, 445 insertions(+) create mode 100644 app/src/main/java/app/gamenative/utils/installscript/InstallScriptParser.kt create mode 100644 app/src/test/java/app/gamenative/utils/installscript/InstallScriptParserTest.kt diff --git a/app/src/main/java/app/gamenative/utils/installscript/InstallScriptParser.kt b/app/src/main/java/app/gamenative/utils/installscript/InstallScriptParser.kt new file mode 100644 index 0000000000..99904e216a --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/installscript/InstallScriptParser.kt @@ -0,0 +1,220 @@ +package app.gamenative.utils.installscript + +import `in`.dragonbra.javasteam.types.KeyValue +import com.winlator.xenvironment.ImageFs +import timber.log.Timber +import java.io.File + +object InstallScriptParser { + + fun parse(file: File, installDir: String, language: String = "english"): InstallScript { + return try { + val content = file.readText() + parseFromString(content, installDir, language, sourcePath = file.absolutePath) + } catch (e: Exception) { + Timber.w(e, "InstallScriptParser: failed to read file ${file.absolutePath}") + InstallScript(sourcePath = file.absolutePath) + } + } + + fun parseFromString( + content: String, + installDir: String, + language: String = "english", + sourcePath: String = "", + ): InstallScript { + val root = try { + KeyValue.loadFromString(content) + } catch (e: Exception) { + Timber.w(e, "InstallScriptParser: failed to parse VDF content") + return InstallScript(sourcePath = sourcePath) + } + + if (root == null) { + Timber.w("InstallScriptParser: KeyValue.loadFromString returned null") + return InstallScript(sourcePath = sourcePath) + } + + val envVars = buildEnvVarMap(installDir) + + val registryActions = root.children + .firstOrNull { it.name.equals("Registry", ignoreCase = true) } + ?.let { parseRegistrySection(it, envVars, language) } + ?: emptyList() + + val runProcessActions = root.children + .firstOrNull { it.name.equals("Run Process", ignoreCase = true) } + ?.let { parseRunProcessSection(it, envVars) } + ?: emptyList() + + return InstallScript( + sourcePath = sourcePath, + registryActions = registryActions, + runProcessActions = runProcessActions, + ) + } + + private fun buildEnvVarMap(installDir: String): Map { + val user = ImageFs.USER + return mapOf( + "INSTALLDIR" to installDir, + "ROOTDRIVE" to "C", + "APPDATA" to "C:\\users\\$user\\AppData\\Roaming", + "LOCALAPPDATA" to "C:\\users\\$user\\AppData\\Local", + "USER_MYDOCS" to "C:\\users\\$user\\Documents", + "COMMON_MYDOCS" to "C:\\users\\Public\\Documents", + "WinDir" to "C:\\windows", + "STEAMPATH" to "C:\\Program Files (x86)\\Steam", + ) + } + + private fun expandEnvVars(value: String, envVars: Map): String { + var result = value + // Build a case-insensitive lookup by lowercasing all keys + val lowerEnvVars = envVars.entries.associate { (k, v) -> k.lowercase() to v } + val regex = Regex("%([^%]+)%") + result = regex.replace(result) { matchResult -> + val varName = matchResult.groupValues[1] + lowerEnvVars[varName.lowercase()] ?: matchResult.value + } + return result + } + + private fun parseRegistrySection( + registryNode: KeyValue, + envVars: Map, + language: String, + ): List { + val actions = mutableListOf() + for (keyNode in registryNode.children) { + val keyPath = expandEnvVars(keyNode.name ?: continue, envVars) + val baseStrings = mutableMapOf() + val baseDwords = mutableMapOf() + val languageOverrides = mutableMapOf() + + for (child in keyNode.children) { + val childName = child.name ?: continue + when { + childName.equals("string", ignoreCase = true) -> { + for (entry in child.children) { + val entryName = entry.name ?: continue + baseStrings[entryName] = expandEnvVars(entry.value ?: "", envVars) + } + } + childName.equals("dword", ignoreCase = true) -> { + for (entry in child.children) { + val entryName = entry.name ?: continue + val raw = entry.value ?: "0" + baseDwords[entryName] = raw.toLongOrNull() ?: 0L + } + } + else -> { + // Language override block + val langStrings = mutableMapOf() + val langDwords = mutableMapOf() + for (langChild in child.children) { + val langChildName = langChild.name ?: continue + when { + langChildName.equals("string", ignoreCase = true) -> { + for (entry in langChild.children) { + val entryName = entry.name ?: continue + langStrings[entryName] = expandEnvVars(entry.value ?: "", envVars) + } + } + langChildName.equals("dword", ignoreCase = true) -> { + for (entry in langChild.children) { + val entryName = entry.name ?: continue + val raw = entry.value ?: "0" + langDwords[entryName] = raw.toLongOrNull() ?: 0L + } + } + } + } + if (langStrings.isNotEmpty() || langDwords.isNotEmpty()) { + languageOverrides[childName] = RegistryValues( + strings = langStrings, + dwords = langDwords, + ) + } + } + } + } + + actions.add( + RegistryAction( + keyPath = keyPath, + values = RegistryValues(strings = baseStrings, dwords = baseDwords), + languageOverrides = languageOverrides, + ), + ) + } + return actions + } + + private fun parseRunProcessSection( + runProcessNode: KeyValue, + envVars: Map, + ): List { + val actions = mutableListOf() + for (entryNode in runProcessNode.children) { + val name = entryNode.name ?: continue + var process = "" + var command = "" + var hasRunKey: String? = null + var noCleanUp = false + var minimumHasRunValue = 0 + var requirementOS: OSRequirement? = null + var asCurrentUser = false + + for (child in entryNode.children) { + val childName = child.name ?: continue + when { + childName.startsWith("Process", ignoreCase = true) -> { + process = expandEnvVars(child.value ?: "", envVars) + } + childName.startsWith("Command", ignoreCase = true) -> { + command = expandEnvVars(child.value ?: "", envVars) + } + childName.equals("HasRunKey", ignoreCase = true) -> { + hasRunKey = expandEnvVars(child.value ?: "", envVars) + } + childName.equals("NoCleanUp", ignoreCase = true) -> { + noCleanUp = (child.value ?: "0") == "1" + } + childName.equals("MinimumHasRunValue", ignoreCase = true) -> { + minimumHasRunValue = child.value?.toIntOrNull() ?: 0 + } + childName.equals("Requirement_OS", ignoreCase = true) -> { + val is64Bit = child.children + .firstOrNull { it.name?.equals("Is64BitWindows", ignoreCase = true) == true } + ?.value + ?.let { it == "1" } + val osType = child.children + .firstOrNull { it.name?.equals("OSType", ignoreCase = true) == true } + ?.value + requirementOS = OSRequirement(is64BitWindows = is64Bit, osType = osType) + } + childName.equals("AsCurrentUser", ignoreCase = true) -> { + asCurrentUser = (child.value ?: "0") == "1" + } + } + } + + if (process.isNotEmpty()) { + actions.add( + RunProcessAction( + name = name, + process = process, + command = command, + hasRunKey = hasRunKey, + noCleanUp = noCleanUp, + minimumHasRunValue = minimumHasRunValue, + requirementOS = requirementOS, + asCurrentUser = asCurrentUser, + ), + ) + } + } + return actions + } +} diff --git a/app/src/test/java/app/gamenative/utils/installscript/InstallScriptParserTest.kt b/app/src/test/java/app/gamenative/utils/installscript/InstallScriptParserTest.kt new file mode 100644 index 0000000000..37b8326deb --- /dev/null +++ b/app/src/test/java/app/gamenative/utils/installscript/InstallScriptParserTest.kt @@ -0,0 +1,225 @@ +package app.gamenative.utils.installscript + +import com.winlator.xenvironment.ImageFs +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class InstallScriptParserTest { + + private val installDir = "A:" + private val user = ImageFs.USER + + // ----------------------------------------------------------------------- + // Registry tests + // ----------------------------------------------------------------------- + + @Test + fun parseRegistry_parsesStringValues() { + val vdf = """ + "InstallScript" + { + "Registry" + { + "HKLM\\Software\\Valve\\Half-Life" + { + "string" + { + "InstallPath" "%INSTALLDIR%" + "Version" "1.1.0.0" + } + } + } + } + """.trimIndent() + + val result = InstallScriptParser.parseFromString(vdf, installDir) + assertEquals(1, result.registryActions.size) + val action = result.registryActions[0] + assertEquals("HKLM\\Software\\Valve\\Half-Life", action.keyPath) + assertEquals(installDir, action.values.strings["InstallPath"]) + assertEquals("1.1.0.0", action.values.strings["Version"]) + assertTrue(action.values.dwords.isEmpty()) + } + + @Test + fun parseRegistry_parsesDwordValues() { + val vdf = """ + "InstallScript" + { + "Registry" + { + "HKLM\\Software\\Valve\\Half-Life" + { + "dword" + { + "PatchVersion" "1" + "Installed" "42" + } + } + } + } + """.trimIndent() + + val result = InstallScriptParser.parseFromString(vdf, installDir) + assertEquals(1, result.registryActions.size) + val action = result.registryActions[0] + assertEquals(1L, action.values.dwords["PatchVersion"]) + assertEquals(42L, action.values.dwords["Installed"]) + assertTrue(action.values.strings.isEmpty()) + } + + @Test + fun parseRegistry_parsesLanguageOverrides() { + val vdf = """ + "InstallScript" + { + "Registry" + { + "HKLM\\Software\\Valve\\Half-Life" + { + "string" + { + "DisplayName" "Half-Life" + } + "french" + { + "string" + { + "DisplayName" "Half-Life FR" + } + "dword" + { + "LangId" "12" + } + } + } + } + } + """.trimIndent() + + val result = InstallScriptParser.parseFromString(vdf, installDir) + assertEquals(1, result.registryActions.size) + val action = result.registryActions[0] + assertEquals("Half-Life", action.values.strings["DisplayName"]) + val frenchOverride = action.languageOverrides["french"] + assertEquals("Half-Life FR", frenchOverride?.strings?.get("DisplayName")) + assertEquals(12L, frenchOverride?.dwords?.get("LangId")) + } + + @Test + fun parseRegistry_expandsEnvVars() { + val vdf = """ + "InstallScript" + { + "Registry" + { + "HKLM\\Software\\MyGame" + { + "string" + { + "InstallPath" "%INSTALLDIR%" + "AppData" "%APPDATA%\\MyGame" + } + } + } + } + """.trimIndent() + + val result = InstallScriptParser.parseFromString(vdf, installDir) + assertEquals(1, result.registryActions.size) + val strings = result.registryActions[0].values.strings + assertEquals(installDir, strings["InstallPath"]) + assertEquals("C:\\users\\$user\\AppData\\Roaming\\MyGame", strings["AppData"]) + } + + // ----------------------------------------------------------------------- + // Run Process tests + // ----------------------------------------------------------------------- + + @Test + fun parseRunProcess_parsesEntries() { + val vdf = """ + "InstallScript" + { + "Run Process" + { + "DirectX" + { + "HasRunKey" "HKLM\\Software\\Valve\\Steam\\Apps\\70" + "Process 1" "%INSTALLDIR%\\DirectX\\DXSETUP.exe" + "Command 1" "/silent" + "NoCleanUp" "1" + } + } + } + """.trimIndent() + + val result = InstallScriptParser.parseFromString(vdf, installDir) + assertEquals(1, result.runProcessActions.size) + val action = result.runProcessActions[0] + assertEquals("DirectX", action.name) + assertEquals("$installDir\\DirectX\\DXSETUP.exe", action.process) + assertEquals("/silent", action.command) + assertEquals("HKLM\\Software\\Valve\\Steam\\Apps\\70", action.hasRunKey) + assertTrue(action.noCleanUp) + } + + @Test + fun parseRunProcess_parsesOSRequirement() { + val vdf = """ + "InstallScript" + { + "Run Process" + { + "VCRedist" + { + "Process 1" "%INSTALLDIR%\\vcredist_x64.exe" + "Requirement_OS" + { + "Is64BitWindows" "1" + "OSType" "win7" + } + } + } + } + """.trimIndent() + + val result = InstallScriptParser.parseFromString(vdf, installDir) + assertEquals(1, result.runProcessActions.size) + val action = result.runProcessActions[0] + assertEquals("VCRedist", action.name) + val req = action.requirementOS + assertEquals(true, req?.is64BitWindows) + assertEquals("win7", req?.osType) + } + + // ----------------------------------------------------------------------- + // Edge cases + // ----------------------------------------------------------------------- + + @Test + fun parse_returnsEmptyForMissingSections() { + val vdf = """ + "InstallScript" + { + } + """.trimIndent() + + val result = InstallScriptParser.parseFromString(vdf, installDir) + assertTrue(result.registryActions.isEmpty()) + assertTrue(result.runProcessActions.isEmpty()) + } + + @Test + fun parse_returnsEmptyForInvalidVdf() { + val invalid = "this is not valid vdf content {{ [{{" + val result = InstallScriptParser.parseFromString(invalid, installDir) + assertTrue(result.registryActions.isEmpty()) + assertTrue(result.runProcessActions.isEmpty()) + } +} From a7181d9942d4e1a1824dc1f5bd6e3c85ee677ecf Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 20 May 2026 13:24:31 +0200 Subject: [PATCH 04/10] feat: implement InstallScript executor with registry writes and run process building --- .../app/gamenative/utils/PreInstallSteps.kt | 2 +- .../installscript/InstallScriptExecutor.kt | 181 +++++++++++++++ .../InstallScriptExecutorTest.kt | 216 ++++++++++++++++++ 3 files changed, 398 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/app/gamenative/utils/installscript/InstallScriptExecutor.kt create mode 100644 app/src/test/java/app/gamenative/utils/installscript/InstallScriptExecutorTest.kt diff --git a/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt b/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt index c1a76d766e..4ab66fdd2e 100644 --- a/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt +++ b/app/src/main/java/app/gamenative/utils/PreInstallSteps.kt @@ -106,7 +106,7 @@ object PreInstallSteps { return "wine explorer /desktop=shell,$screenInfo $wrapped" } - private fun getGameDir(container: Container): File? { + internal fun getGameDir(container: Container): File? { for (drive in Container.drivesIterator(container.drives)) { if (drive[0].equals("A", ignoreCase = true)) return File(drive[1]) } diff --git a/app/src/main/java/app/gamenative/utils/installscript/InstallScriptExecutor.kt b/app/src/main/java/app/gamenative/utils/installscript/InstallScriptExecutor.kt new file mode 100644 index 0000000000..0de28e6e2f --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/installscript/InstallScriptExecutor.kt @@ -0,0 +1,181 @@ +package app.gamenative.utils.installscript + +import app.gamenative.data.AppInfo +import app.gamenative.data.SteamApp +import com.winlator.container.Container +import com.winlator.core.WineRegistryEditor +import timber.log.Timber +import java.io.File + +object InstallScriptExecutor { + + private const val CLAIMED_OS_TYPE = "10" + + data class RunProcessCommand( + val executable: String, + val hasRunKey: String?, + ) + + fun collectScripts( + steamApp: SteamApp, + appInfo: AppInfo, + gameDir: File, + installDir: String, + language: String, + ): List { + val downloadedDepotIds = appInfo.downloadedDepots.toSet() + return steamApp.depots + .filter { (depotId, depot) -> + depotId in downloadedDepotIds && depot.installScript.isNotEmpty() + } + .mapNotNull { (_, depot) -> + val scriptFile = File(gameDir, depot.installScript) + if (!scriptFile.exists()) { + Timber.w("InstallScript file not found: ${scriptFile.absolutePath}") + return@mapNotNull null + } + InstallScriptParser.parse(scriptFile, installDir, language) + } + } + + fun applyRegistryKeys(container: Container, scripts: List, language: String) { + val systemRegFile = File(container.rootDir, ".wine/system.reg") + val userRegFile = File(container.rootDir, ".wine/user.reg") + + val systemActions = mutableListOf>() + val userActions = mutableListOf>() + + for (script in scripts) { + for (action in script.registryActions) { + val mergedValues = mergeWithLanguage(action, language) + val strippedKey = stripHivePrefix(action.keyPath) + + if (action.keyPath.startsWith("HKLM", ignoreCase = true) || + action.keyPath.startsWith("HKEY_LOCAL_MACHINE", ignoreCase = true) + ) { + systemActions.add(strippedKey to mergedValues) + } else { + userActions.add(strippedKey to mergedValues) + } + } + } + + if (systemActions.isNotEmpty() && systemRegFile.exists()) { + writeRegistryValues(systemRegFile, systemActions) + } + if (userActions.isNotEmpty() && userRegFile.exists()) { + writeRegistryValues(userRegFile, userActions) + } + } + + fun getRunProcessCommands( + container: Container, + scripts: List, + screenInfo: String, + is64Bit: Boolean, + ): List { + val systemRegFile = File(container.rootDir, ".wine/system.reg") + val commands = mutableListOf() + + for (script in scripts) { + for (action in script.runProcessActions) { + if (!matchesOS(action.requirementOS, is64Bit)) continue + if (hasAlreadyRun(systemRegFile, action)) continue + + val cmdLine = if (action.command.isNotEmpty()) { + "${action.process} ${action.command}" + } else { + action.process + } + val wrapped = wrapAsGuestExecutable(cmdLine, screenInfo) + + val effectiveHasRunKey = action.hasRunKey + ?: "Software\\GameNative\\InstallScript\\${script.sourcePath.hashCode()}\\${action.name}" + + commands.add(RunProcessCommand(wrapped, effectiveHasRunKey)) + } + } + return commands + } + + fun markRunProcessComplete(container: Container, hasRunKey: String) { + val systemRegFile = File(container.rootDir, ".wine/system.reg") + if (!systemRegFile.exists()) return + try { + WineRegistryEditor(systemRegFile).use { editor -> + editor.setCreateKeyIfNotExist(true) + val keyPath = hasRunKey.substringBeforeLast("\\") + val valueName = hasRunKey.substringAfterLast("\\") + editor.setDwordValue(stripHivePrefix(keyPath), valueName, 1) + } + } catch (e: Exception) { + Timber.w(e, "Failed to mark run process complete: $hasRunKey") + } + } + + internal fun mergeWithLanguage(action: RegistryAction, language: String): RegistryValues { + val langOverride = action.languageOverrides[language.lowercase()] + ?: return action.values + return RegistryValues( + strings = action.values.strings + langOverride.strings, + dwords = action.values.dwords + langOverride.dwords, + ) + } + + internal fun matchesOS(requirement: OSRequirement?, is64Bit: Boolean): Boolean { + if (requirement == null) return true + if (requirement.is64BitWindows != null && requirement.is64BitWindows != is64Bit) return false + if (requirement.osType != null && requirement.osType != CLAIMED_OS_TYPE) return false + return true + } + + internal fun stripHivePrefix(keyPath: String): String { + return keyPath + .removePrefix("HKLM\\") + .removePrefix("HKEY_LOCAL_MACHINE\\") + .removePrefix("HKCU\\") + .removePrefix("HKEY_CURRENT_USER\\") + } + + private fun hasAlreadyRun(systemRegFile: File, action: RunProcessAction): Boolean { + if (action.hasRunKey == null) return false + if (!systemRegFile.exists()) return false + return try { + WineRegistryEditor(systemRegFile).use { editor -> + val keyPath = action.hasRunKey.substringBeforeLast("\\") + val valueName = action.hasRunKey.substringAfterLast("\\") + val currentValue = editor.getDwordValue( + stripHivePrefix(keyPath), valueName, 0 + ) ?: 0 + currentValue >= maxOf(1, action.minimumHasRunValue) + } + } catch (e: Exception) { + false + } + } + + private fun writeRegistryValues(regFile: File, actions: List>) { + try { + WineRegistryEditor(regFile).use { editor -> + editor.setCreateKeyIfNotExist(true) + for ((key, values) in actions) { + for ((name, value) in values.strings) { + val regName = if (name == "(Default)") null else name + editor.setStringValue(key, regName, value) + } + for ((name, value) in values.dwords) { + val regName = if (name == "(Default)") null else name + editor.setDwordValue(key, regName, value.toInt()) + } + } + } + } catch (e: Exception) { + Timber.e(e, "Failed to write InstallScript registry values") + } + } + + private fun wrapAsGuestExecutable(cmdChain: String, screenInfo: String): String { + val wrapped = "winhandler.exe cmd /c \"$cmdChain & taskkill /F /IM explorer.exe & wineserver -k\"" + return "wine explorer /desktop=shell,$screenInfo $wrapped" + } +} diff --git a/app/src/test/java/app/gamenative/utils/installscript/InstallScriptExecutorTest.kt b/app/src/test/java/app/gamenative/utils/installscript/InstallScriptExecutorTest.kt new file mode 100644 index 0000000000..81c30e6c9c --- /dev/null +++ b/app/src/test/java/app/gamenative/utils/installscript/InstallScriptExecutorTest.kt @@ -0,0 +1,216 @@ +package app.gamenative.utils.installscript + +import app.gamenative.data.AppInfo +import app.gamenative.data.DepotInfo +import app.gamenative.data.SteamApp +import app.gamenative.enums.OS +import app.gamenative.enums.OSArch +import app.gamenative.service.SteamService +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File +import java.util.EnumSet +import kotlin.io.path.createTempDirectory + +@RunWith(RobolectricTestRunner::class) +class InstallScriptExecutorTest { + + private lateinit var tempDir: File + + @Before + fun setUp() { + tempDir = createTempDirectory(prefix = "installscript-executor-test").toFile() + } + + @After + fun tearDown() { + tempDir.deleteRecursively() + } + + private fun makeDepotInfo(depotId: Int, installScript: String = ""): DepotInfo { + return DepotInfo( + depotId = depotId, + dlcAppId = SteamService.INVALID_APP_ID, + depotFromApp = SteamService.INVALID_APP_ID, + sharedInstall = false, + osList = EnumSet.of(OS.windows), + osArch = OSArch.Arch32, + manifests = emptyMap(), + encryptedManifests = emptyMap(), + installScript = installScript, + ) + } + + private fun makeSteamApp(depots: Map): SteamApp { + return SteamApp(id = 12345, depots = depots) + } + + private fun makeAppInfo(downloadedDepots: List): AppInfo { + return AppInfo(id = 12345, downloadedDepots = downloadedDepots) + } + + private fun writeVdfScript(fileName: String): File { + val vdf = """ + "InstallScript" + { + "Registry" + { + "HKLM\Software\Test\App" + { + "String" + { + "InstallPath" "%INSTALLDIR%" + } + } + } + } + """.trimIndent() + val file = File(tempDir, fileName) + file.writeText(vdf) + return file + } + + // --- collectScripts tests --- + + @Test + fun collectScripts_returnsScriptsFromDownloadedDepots() { + val scriptFileName = "installscript.vdf" + writeVdfScript(scriptFileName) + + val depotId = 101 + val depots = mapOf(depotId to makeDepotInfo(depotId, scriptFileName)) + val steamApp = makeSteamApp(depots) + val appInfo = makeAppInfo(listOf(depotId)) + + val scripts = InstallScriptExecutor.collectScripts( + steamApp = steamApp, + appInfo = appInfo, + gameDir = tempDir, + installDir = "C:\\Games\\TestGame", + language = "english", + ) + + assertEquals(1, scripts.size) + assertTrue(scripts[0].registryActions.isNotEmpty()) + } + + @Test + fun collectScripts_skipsDepotsNotDownloaded() { + val scriptFileName = "installscript.vdf" + writeVdfScript(scriptFileName) + + val depotId = 101 + val depots = mapOf(depotId to makeDepotInfo(depotId, scriptFileName)) + val steamApp = makeSteamApp(depots) + val appInfo = makeAppInfo(emptyList()) // not downloaded + + val scripts = InstallScriptExecutor.collectScripts( + steamApp = steamApp, + appInfo = appInfo, + gameDir = tempDir, + installDir = "C:\\Games\\TestGame", + language = "english", + ) + + assertTrue(scripts.isEmpty()) + } + + // --- mergeWithLanguage tests --- + + @Test + fun mergeWithLanguage_appliesOverrides() { + val action = RegistryAction( + keyPath = "HKLM\\Software\\Test", + values = RegistryValues( + strings = mapOf("BaseName" to "BaseValue", "OtherName" to "OtherValue"), + dwords = mapOf("BaseCount" to 1L), + ), + languageOverrides = mapOf( + "french" to RegistryValues( + strings = mapOf("BaseName" to "FrenchValue"), + dwords = mapOf("FrenchCount" to 42L), + ), + ), + ) + + val merged = InstallScriptExecutor.mergeWithLanguage(action, "french") + + assertEquals("FrenchValue", merged.strings["BaseName"]) + assertEquals("OtherValue", merged.strings["OtherName"]) + assertEquals(1L, merged.dwords["BaseCount"]) + assertEquals(42L, merged.dwords["FrenchCount"]) + } + + @Test + fun mergeWithLanguage_returnsBaseWhenNoOverride() { + val action = RegistryAction( + keyPath = "HKLM\\Software\\Test", + values = RegistryValues( + strings = mapOf("Name" to "Value"), + ), + languageOverrides = mapOf( + "french" to RegistryValues(strings = mapOf("Name" to "FrenchValue")), + ), + ) + + val merged = InstallScriptExecutor.mergeWithLanguage(action, "german") + + assertEquals("Value", merged.strings["Name"]) + assertFalse(merged.strings.containsKey("FrenchValue")) + } + + // --- matchesOS tests --- + + @Test + fun matchesOS_acceptsWhenNoRequirement() { + assertTrue(InstallScriptExecutor.matchesOS(null, is64Bit = false)) + assertTrue(InstallScriptExecutor.matchesOS(null, is64Bit = true)) + } + + @Test + fun matchesOS_rejects32BitWhen64BitRequired() { + val requirement = OSRequirement(is64BitWindows = true) + assertTrue(InstallScriptExecutor.matchesOS(requirement, is64Bit = true)) + assertFalse(InstallScriptExecutor.matchesOS(requirement, is64Bit = false)) + } + + @Test + fun matchesOS_rejectsWrongOsType() { + val requirement = OSRequirement(osType = "7") + assertFalse(InstallScriptExecutor.matchesOS(requirement, is64Bit = false)) + } + + @Test + fun matchesOS_acceptsMatchingOsType() { + val requirement = OSRequirement(osType = "10") + assertTrue(InstallScriptExecutor.matchesOS(requirement, is64Bit = false)) + } + + // --- stripHivePrefix tests --- + + @Test + fun stripHivePrefix_stripsHKLM() { + assertEquals("Software\\Test", InstallScriptExecutor.stripHivePrefix("HKLM\\Software\\Test")) + } + + @Test + fun stripHivePrefix_stripsHKCU() { + assertEquals("Software\\Test", InstallScriptExecutor.stripHivePrefix("HKCU\\Software\\Test")) + } + + @Test + fun stripHivePrefix_stripsFullNames() { + assertEquals( + "Software\\Test", + InstallScriptExecutor.stripHivePrefix("HKEY_LOCAL_MACHINE\\Software\\Test"), + ) + assertEquals( + "Software\\Test", + InstallScriptExecutor.stripHivePrefix("HKEY_CURRENT_USER\\Software\\Test"), + ) + } +} From d0902540601566bec5524ae040b47a7f6dfb899c Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 20 May 2026 13:29:11 +0200 Subject: [PATCH 05/10] feat: integrate InstallScript executor into game launch pipeline --- .../ui/screen/xserver/XServerScreen.kt | 72 ++++++++++++++++--- 1 file changed, 62 insertions(+), 10 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..f158bbc958 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 @@ -106,6 +106,7 @@ import app.gamenative.utils.ExecutableSelectionUtils import app.gamenative.utils.LsfgQuickMenuHelper import app.gamenative.utils.ManifestComponentHelper import app.gamenative.utils.PreInstallSteps +import app.gamenative.utils.installscript.InstallScriptExecutor import app.gamenative.utils.SteamTokenLogin import app.gamenative.utils.SteamUtils import app.gamenative.utils.WineProcessSnapshotHelper @@ -3051,6 +3052,7 @@ private fun setupXEnvironment( } var preInstallCommands: List = emptyList() + var installScriptRunProcessCommands = emptyList() var gameExecutable = "" if (container != null) { @@ -3059,6 +3061,39 @@ private fun setupXEnvironment( } catch (e: Exception) { Timber.tag("GameFixes").w(e, "Game fixes failed before launch") } + if (gameSource == GameSource.STEAM) { + try { + val numericGameId = ContainerUtils.extractGameIdFromContainerId(appId) + val steamApp = SteamService.getAppInfoOf(numericGameId) + val appInfoObj = runBlocking(Dispatchers.IO) { + SteamService.instance?.appInfoDao?.get(numericGameId) + } + val gameDir = PreInstallSteps.getGameDir(container) + if (steamApp != null && appInfoObj != null && gameDir != null) { + val installDir = "A:" + val scripts = InstallScriptExecutor.collectScripts( + steamApp = steamApp, + appInfo = appInfoObj, + gameDir = gameDir, + installDir = installDir, + language = container.language, + ) + if (scripts.isNotEmpty()) { + Timber.tag("InstallScript").i("Applying registry keys from ${scripts.size} install script(s)") + InstallScriptExecutor.applyRegistryKeys(container, scripts, container.language) + installScriptRunProcessCommands = InstallScriptExecutor.getRunProcessCommands( + container = container, + scripts = scripts, + screenInfo = xServer.screenInfo.toString(), + is64Bit = container.isWoW64Mode, + ) + } + } + } catch (e: Exception) { + Timber.tag("InstallScript").w(e, "InstallScript execution failed") + } + } + if (container.startupSelection == Container.STARTUP_SELECTION_AGGRESSIVE) { if (container.containerVariant.equals(Container.BIONIC)){ Timber.d("Incorrect startup selection detected. Reverting to essential startup selection") @@ -3090,8 +3125,9 @@ private fun setupXEnvironment( xServer.screenInfo.toString(), containerVariantChanged, ) - guestProgramLauncherComponent.guestExecutable = - preInstallCommands.firstOrNull()?.executable ?: gameExecutable + val firstChainedExecutable = preInstallCommands.firstOrNull()?.executable + ?: installScriptRunProcessCommands.firstOrNull()?.executable + guestProgramLauncherComponent.guestExecutable = firstChainedExecutable ?: gameExecutable guestProgramLauncherComponent.isWoW64Mode = wow64Mode // Set steam type for selecting appropriate box64rc guestProgramLauncherComponent.setSteamType(container.getSteamType()) @@ -3132,7 +3168,7 @@ private fun setupXEnvironment( containerVariantChanged = containerVariantChanged, onError = onGameLaunchError ) - if (preInstallCommands.isNotEmpty()) { + if (preInstallCommands.isNotEmpty() || installScriptRunProcessCommands.isNotEmpty()) { PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Installing prerequisites...")) } else { PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Launching game...")) @@ -3211,7 +3247,24 @@ private fun setupXEnvironment( PluviaApp.events.emit(AndroidEvent.GuestProgramTerminated) } - fun chainPreInstallSteps(remaining: List) { + data class ChainedCommand( + val executable: String, + val onComplete: () -> Unit, + ) + + val chainedPreInstall = preInstallCommands.map { cmd -> + ChainedCommand(cmd.executable) { PreInstallSteps.markStepDone(container, cmd.marker) } + } + val chainedInstallScript = installScriptRunProcessCommands.map { cmd -> + ChainedCommand(cmd.executable) { + if (cmd.hasRunKey != null) { + InstallScriptExecutor.markRunProcessComplete(container, cmd.hasRunKey) + } + } + } + val allChainedCommands = chainedPreInstall + chainedInstallScript + + fun chainCommands(remaining: List) { if (remaining.isEmpty()) { guestProgramLauncherComponent.setGuestExecutable(gameExecutable) guestProgramLauncherComponent.setTerminationCallback(gameTerminationCallback) @@ -3219,13 +3272,12 @@ private fun setupXEnvironment( } guestProgramLauncherComponent.setGuestExecutable(remaining.first().executable) guestProgramLauncherComponent.setTerminationCallback { _ -> - val current = remaining.first() - PreInstallSteps.markStepDone(container, current.marker) + remaining.first().onComplete() guestProgramLauncherComponent.setPreUnpack(null) try { guestProgramLauncherComponent.execShellCommand("wineserver -k") } catch (e: Exception) { - Timber.w(e, "wineserver -k between pre-install steps (non-fatal)") + Timber.w(e, "wineserver -k between chained commands (non-fatal)") } val nextRemaining = remaining.drop(1) if (nextRemaining.isEmpty()) { @@ -3233,13 +3285,13 @@ private fun setupXEnvironment( } else { PluviaApp.events.emit(AndroidEvent.SetBootingSplashText("Installing prerequisites...")) } - chainPreInstallSteps(nextRemaining) + chainCommands(nextRemaining) guestProgramLauncherComponent.start() } } - if (preInstallCommands.isNotEmpty()) { - chainPreInstallSteps(preInstallCommands) + if (allChainedCommands.isNotEmpty()) { + chainCommands(allChainedCommands) } else { guestProgramLauncherComponent.setTerminationCallback(gameTerminationCallback) } From 6ee10ff2f3f54cac5e3807006b2418051252a46d Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 20 May 2026 13:33:23 +0200 Subject: [PATCH 06/10] style: fix import ordering and trailing comma in InstallScript files --- .../gamenative/utils/installscript/InstallScriptExecutor.kt | 4 ++-- .../app/gamenative/utils/installscript/InstallScriptParser.kt | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/app/gamenative/utils/installscript/InstallScriptExecutor.kt b/app/src/main/java/app/gamenative/utils/installscript/InstallScriptExecutor.kt index 0de28e6e2f..ccd2203a3d 100644 --- a/app/src/main/java/app/gamenative/utils/installscript/InstallScriptExecutor.kt +++ b/app/src/main/java/app/gamenative/utils/installscript/InstallScriptExecutor.kt @@ -4,8 +4,8 @@ import app.gamenative.data.AppInfo import app.gamenative.data.SteamApp import com.winlator.container.Container import com.winlator.core.WineRegistryEditor -import timber.log.Timber import java.io.File +import timber.log.Timber object InstallScriptExecutor { @@ -145,7 +145,7 @@ object InstallScriptExecutor { val keyPath = action.hasRunKey.substringBeforeLast("\\") val valueName = action.hasRunKey.substringAfterLast("\\") val currentValue = editor.getDwordValue( - stripHivePrefix(keyPath), valueName, 0 + stripHivePrefix(keyPath), valueName, 0, ) ?: 0 currentValue >= maxOf(1, action.minimumHasRunValue) } diff --git a/app/src/main/java/app/gamenative/utils/installscript/InstallScriptParser.kt b/app/src/main/java/app/gamenative/utils/installscript/InstallScriptParser.kt index 99904e216a..d15e5be238 100644 --- a/app/src/main/java/app/gamenative/utils/installscript/InstallScriptParser.kt +++ b/app/src/main/java/app/gamenative/utils/installscript/InstallScriptParser.kt @@ -1,9 +1,9 @@ package app.gamenative.utils.installscript -import `in`.dragonbra.javasteam.types.KeyValue import com.winlator.xenvironment.ImageFs -import timber.log.Timber +import `in`.dragonbra.javasteam.types.KeyValue import java.io.File +import timber.log.Timber object InstallScriptParser { From b2e196d0cf2db181db4f100c8d3ca3a6951f2275 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Wed, 20 May 2026 15:04:09 +0200 Subject: [PATCH 07/10] fix: discover install scripts from depot manifests via EDepotFileFlag Install scripts are identified by the EDepotFileFlag.InstallScript flag on files in depot manifests, not from a depot-level metadata key. Rewrite collectScripts to load depot manifests and scan for flagged files. Remove old collectScripts unit tests that relied on the previous metadata-based API; keep utility method tests. Clean up diagnostic debug logging from XServerScreen. --- .../ui/screen/xserver/XServerScreen.kt | 1 + .../installscript/InstallScriptExecutor.kt | 58 +++++++-- .../InstallScriptExecutorTest.kt | 111 ------------------ 3 files changed, 49 insertions(+), 121 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 f158bbc958..f2553de079 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 @@ -3077,6 +3077,7 @@ private fun setupXEnvironment( gameDir = gameDir, installDir = installDir, language = container.language, + appId = numericGameId, ) if (scripts.isNotEmpty()) { Timber.tag("InstallScript").i("Applying registry keys from ${scripts.size} install script(s)") diff --git a/app/src/main/java/app/gamenative/utils/installscript/InstallScriptExecutor.kt b/app/src/main/java/app/gamenative/utils/installscript/InstallScriptExecutor.kt index ccd2203a3d..8100913b67 100644 --- a/app/src/main/java/app/gamenative/utils/installscript/InstallScriptExecutor.kt +++ b/app/src/main/java/app/gamenative/utils/installscript/InstallScriptExecutor.kt @@ -2,9 +2,13 @@ package app.gamenative.utils.installscript import app.gamenative.data.AppInfo import app.gamenative.data.SteamApp +import app.gamenative.service.SteamService import com.winlator.container.Container import com.winlator.core.WineRegistryEditor +import `in`.dragonbra.javasteam.enums.EDepotFileFlag +import `in`.dragonbra.javasteam.types.DepotManifest import java.io.File +import java.util.EnumSet import timber.log.Timber object InstallScriptExecutor { @@ -22,20 +26,54 @@ object InstallScriptExecutor { gameDir: File, installDir: String, language: String, + appId: Int, ): List { + val appDirPath = SteamService.getAppDirPath(appId) val downloadedDepotIds = appInfo.downloadedDepots.toSet() - return steamApp.depots - .filter { (depotId, depot) -> - depotId in downloadedDepotIds && depot.installScript.isNotEmpty() - } - .mapNotNull { (_, depot) -> - val scriptFile = File(gameDir, depot.installScript) - if (!scriptFile.exists()) { - Timber.w("InstallScript file not found: ${scriptFile.absolutePath}") - return@mapNotNull null + val installedBranch = SteamService.getInstalledApp(appId)?.branch ?: "public" + + val scriptPaths = mutableSetOf() + + for ((depotId, depot) in steamApp.depots) { + if (depotId !in downloadedDepotIds) continue + + val mi = depot.manifests[installedBranch] + ?: depot.encryptedManifests[installedBranch] + ?: depot.manifests["public"] + ?: continue + + val manifestFile = "$appDirPath/.DepotDownloader/${depotId}_${mi.gid}.manifest" + val manifest = try { + DepotManifest.loadFromFile(manifestFile) + } catch (e: Exception) { + Timber.tag("InstallScript").d("Could not load manifest for depot $depotId: ${e.message}") + continue + } ?: continue + + manifest.files?.forEach { fileData -> + if (isInstallScript(fileData.flags)) { + val path = fileData.fileName.toString().replace('\\', '/') + scriptPaths.add(path) + Timber.tag("InstallScript").d("Found install script in depot $depotId: $path") } - InstallScriptParser.parse(scriptFile, installDir, language) } + } + + return scriptPaths.mapNotNull { relativePath -> + val scriptFile = File(gameDir, relativePath) + if (!scriptFile.exists()) { + Timber.tag("InstallScript").w("InstallScript file not found: ${scriptFile.absolutePath}") + return@mapNotNull null + } + InstallScriptParser.parse(scriptFile, installDir, language) + } + } + + private fun isInstallScript(flags: Any): Boolean = when (flags) { + is EnumSet<*> -> flags.contains(EDepotFileFlag.InstallScript) + is Int -> (flags and EDepotFileFlag.InstallScript.code()) != 0 + is Long -> (flags and EDepotFileFlag.InstallScript.code().toLong()) != 0L + else -> false } fun applyRegistryKeys(container: Container, scripts: List, language: String) { diff --git a/app/src/test/java/app/gamenative/utils/installscript/InstallScriptExecutorTest.kt b/app/src/test/java/app/gamenative/utils/installscript/InstallScriptExecutorTest.kt index 81c30e6c9c..a3befb04aa 100644 --- a/app/src/test/java/app/gamenative/utils/installscript/InstallScriptExecutorTest.kt +++ b/app/src/test/java/app/gamenative/utils/installscript/InstallScriptExecutorTest.kt @@ -1,124 +1,13 @@ package app.gamenative.utils.installscript -import app.gamenative.data.AppInfo -import app.gamenative.data.DepotInfo -import app.gamenative.data.SteamApp -import app.gamenative.enums.OS -import app.gamenative.enums.OSArch -import app.gamenative.service.SteamService -import org.junit.After import org.junit.Assert.* -import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner -import java.io.File -import java.util.EnumSet -import kotlin.io.path.createTempDirectory @RunWith(RobolectricTestRunner::class) class InstallScriptExecutorTest { - private lateinit var tempDir: File - - @Before - fun setUp() { - tempDir = createTempDirectory(prefix = "installscript-executor-test").toFile() - } - - @After - fun tearDown() { - tempDir.deleteRecursively() - } - - private fun makeDepotInfo(depotId: Int, installScript: String = ""): DepotInfo { - return DepotInfo( - depotId = depotId, - dlcAppId = SteamService.INVALID_APP_ID, - depotFromApp = SteamService.INVALID_APP_ID, - sharedInstall = false, - osList = EnumSet.of(OS.windows), - osArch = OSArch.Arch32, - manifests = emptyMap(), - encryptedManifests = emptyMap(), - installScript = installScript, - ) - } - - private fun makeSteamApp(depots: Map): SteamApp { - return SteamApp(id = 12345, depots = depots) - } - - private fun makeAppInfo(downloadedDepots: List): AppInfo { - return AppInfo(id = 12345, downloadedDepots = downloadedDepots) - } - - private fun writeVdfScript(fileName: String): File { - val vdf = """ - "InstallScript" - { - "Registry" - { - "HKLM\Software\Test\App" - { - "String" - { - "InstallPath" "%INSTALLDIR%" - } - } - } - } - """.trimIndent() - val file = File(tempDir, fileName) - file.writeText(vdf) - return file - } - - // --- collectScripts tests --- - - @Test - fun collectScripts_returnsScriptsFromDownloadedDepots() { - val scriptFileName = "installscript.vdf" - writeVdfScript(scriptFileName) - - val depotId = 101 - val depots = mapOf(depotId to makeDepotInfo(depotId, scriptFileName)) - val steamApp = makeSteamApp(depots) - val appInfo = makeAppInfo(listOf(depotId)) - - val scripts = InstallScriptExecutor.collectScripts( - steamApp = steamApp, - appInfo = appInfo, - gameDir = tempDir, - installDir = "C:\\Games\\TestGame", - language = "english", - ) - - assertEquals(1, scripts.size) - assertTrue(scripts[0].registryActions.isNotEmpty()) - } - - @Test - fun collectScripts_skipsDepotsNotDownloaded() { - val scriptFileName = "installscript.vdf" - writeVdfScript(scriptFileName) - - val depotId = 101 - val depots = mapOf(depotId to makeDepotInfo(depotId, scriptFileName)) - val steamApp = makeSteamApp(depots) - val appInfo = makeAppInfo(emptyList()) // not downloaded - - val scripts = InstallScriptExecutor.collectScripts( - steamApp = steamApp, - appInfo = appInfo, - gameDir = tempDir, - installDir = "C:\\Games\\TestGame", - language = "english", - ) - - assertTrue(scripts.isEmpty()) - } - // --- mergeWithLanguage tests --- @Test From 40fff537c37918ad9f5f692deb8d7ca4a006afc1 Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Thu, 21 May 2026 08:49:26 +0200 Subject: [PATCH 08/10] fix: address PR review findings in InstallScript executor and parser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Compute effectiveHasRunKey before skip-check so fallback-keyed actions don't re-run on every launch - Resolve registry file from hive prefix (HKCU → user.reg) instead of hardcoding system.reg in hasAlreadyRun/markRunProcessComplete - Make stripHivePrefix case-insensitive to match applyRegistryKeys - Lowercase language override keys when parsing to match mergeWithLanguage lookup --- .../installscript/InstallScriptExecutor.kt | 49 ++++++++++++------- .../installscript/InstallScriptParser.kt | 2 +- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/app/src/main/java/app/gamenative/utils/installscript/InstallScriptExecutor.kt b/app/src/main/java/app/gamenative/utils/installscript/InstallScriptExecutor.kt index 8100913b67..470b916537 100644 --- a/app/src/main/java/app/gamenative/utils/installscript/InstallScriptExecutor.kt +++ b/app/src/main/java/app/gamenative/utils/installscript/InstallScriptExecutor.kt @@ -112,13 +112,16 @@ object InstallScriptExecutor { screenInfo: String, is64Bit: Boolean, ): List { - val systemRegFile = File(container.rootDir, ".wine/system.reg") val commands = mutableListOf() for (script in scripts) { for (action in script.runProcessActions) { if (!matchesOS(action.requirementOS, is64Bit)) continue - if (hasAlreadyRun(systemRegFile, action)) continue + + val effectiveHasRunKey = action.hasRunKey + ?: "Software\\GameNative\\InstallScript\\${script.sourcePath.hashCode()}\\${action.name}" + + if (hasAlreadyRun(container, effectiveHasRunKey, action.minimumHasRunValue)) continue val cmdLine = if (action.command.isNotEmpty()) { "${action.process} ${action.command}" @@ -127,9 +130,6 @@ object InstallScriptExecutor { } val wrapped = wrapAsGuestExecutable(cmdLine, screenInfo) - val effectiveHasRunKey = action.hasRunKey - ?: "Software\\GameNative\\InstallScript\\${script.sourcePath.hashCode()}\\${action.name}" - commands.add(RunProcessCommand(wrapped, effectiveHasRunKey)) } } @@ -137,10 +137,10 @@ object InstallScriptExecutor { } fun markRunProcessComplete(container: Container, hasRunKey: String) { - val systemRegFile = File(container.rootDir, ".wine/system.reg") - if (!systemRegFile.exists()) return + val regFile = resolveRegFile(container, hasRunKey) + if (!regFile.exists()) return try { - WineRegistryEditor(systemRegFile).use { editor -> + WineRegistryEditor(regFile).use { editor -> editor.setCreateKeyIfNotExist(true) val keyPath = hasRunKey.substringBeforeLast("\\") val valueName = hasRunKey.substringAfterLast("\\") @@ -168,24 +168,35 @@ object InstallScriptExecutor { } internal fun stripHivePrefix(keyPath: String): String { + val prefixes = listOf("HKEY_LOCAL_MACHINE\\", "HKEY_CURRENT_USER\\", "HKLM\\", "HKCU\\") + for (prefix in prefixes) { + if (keyPath.startsWith(prefix, ignoreCase = true)) { + return keyPath.substring(prefix.length) + } + } return keyPath - .removePrefix("HKLM\\") - .removePrefix("HKEY_LOCAL_MACHINE\\") - .removePrefix("HKCU\\") - .removePrefix("HKEY_CURRENT_USER\\") } - private fun hasAlreadyRun(systemRegFile: File, action: RunProcessAction): Boolean { - if (action.hasRunKey == null) return false - if (!systemRegFile.exists()) return false + private fun isHkcuKey(keyPath: String): Boolean = + keyPath.startsWith("HKCU", ignoreCase = true) || + keyPath.startsWith("HKEY_CURRENT_USER", ignoreCase = true) + + private fun resolveRegFile(container: Container, hasRunKey: String): File { + val regName = if (isHkcuKey(hasRunKey)) ".wine/user.reg" else ".wine/system.reg" + return File(container.rootDir, regName) + } + + private fun hasAlreadyRun(container: Container, hasRunKey: String, minimumHasRunValue: Int): Boolean { + val regFile = resolveRegFile(container, hasRunKey) + if (!regFile.exists()) return false return try { - WineRegistryEditor(systemRegFile).use { editor -> - val keyPath = action.hasRunKey.substringBeforeLast("\\") - val valueName = action.hasRunKey.substringAfterLast("\\") + WineRegistryEditor(regFile).use { editor -> + val keyPath = hasRunKey.substringBeforeLast("\\") + val valueName = hasRunKey.substringAfterLast("\\") val currentValue = editor.getDwordValue( stripHivePrefix(keyPath), valueName, 0, ) ?: 0 - currentValue >= maxOf(1, action.minimumHasRunValue) + currentValue >= maxOf(1, minimumHasRunValue) } } catch (e: Exception) { false diff --git a/app/src/main/java/app/gamenative/utils/installscript/InstallScriptParser.kt b/app/src/main/java/app/gamenative/utils/installscript/InstallScriptParser.kt index d15e5be238..77efbed859 100644 --- a/app/src/main/java/app/gamenative/utils/installscript/InstallScriptParser.kt +++ b/app/src/main/java/app/gamenative/utils/installscript/InstallScriptParser.kt @@ -131,7 +131,7 @@ object InstallScriptParser { } } if (langStrings.isNotEmpty() || langDwords.isNotEmpty()) { - languageOverrides[childName] = RegistryValues( + languageOverrides[childName.lowercase()] = RegistryValues( strings = langStrings, dwords = langDwords, ) From 88a7d6d54024e140ea514796dc11ff93a7d4c01a Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Thu, 21 May 2026 14:25:00 +0200 Subject: [PATCH 09/10] feat: exit-code-based completion tracking for InstallScript run processes Wrapper now captures installer exit code via &&/|| branching to a file before wineserver cleanup. Only marks HasRunKey complete on exit code 0, matching Steam behavior. Non-zero exits log a warning and retry next launch. --- .../ui/screen/xserver/XServerScreen.kt | 9 ++++++++- .../utils/installscript/InstallScriptExecutor.kt | 16 +++++++++++++++- 2 files changed, 23 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 f2553de079..ec479a025b 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 @@ -3259,7 +3259,14 @@ private fun setupXEnvironment( val chainedInstallScript = installScriptRunProcessCommands.map { cmd -> ChainedCommand(cmd.executable) { if (cmd.hasRunKey != null) { - InstallScriptExecutor.markRunProcessComplete(container, cmd.hasRunKey) + val exitCode = InstallScriptExecutor.readExitCode(container) + if (exitCode == 0) { + InstallScriptExecutor.markRunProcessComplete(container, cmd.hasRunKey) + } else { + Timber.tag("InstallScript").w( + "Run process exited with code $exitCode, will retry next launch", + ) + } } } } diff --git a/app/src/main/java/app/gamenative/utils/installscript/InstallScriptExecutor.kt b/app/src/main/java/app/gamenative/utils/installscript/InstallScriptExecutor.kt index 470b916537..51fd7d9504 100644 --- a/app/src/main/java/app/gamenative/utils/installscript/InstallScriptExecutor.kt +++ b/app/src/main/java/app/gamenative/utils/installscript/InstallScriptExecutor.kt @@ -14,6 +14,7 @@ import timber.log.Timber object InstallScriptExecutor { private const val CLAIMED_OS_TYPE = "10" + private const val EXIT_CODE_FILE = "installscript_exit" data class RunProcessCommand( val executable: String, @@ -223,8 +224,21 @@ object InstallScriptExecutor { } } + fun readExitCode(container: Container): Int { + val exitFile = File(container.rootDir, ".wine/drive_c/$EXIT_CODE_FILE") + return try { + val code = exitFile.readText().trim().toIntOrNull() ?: -1 + exitFile.delete() + code + } catch (e: Exception) { + -1 + } + } + private fun wrapAsGuestExecutable(cmdChain: String, screenInfo: String): String { - val wrapped = "winhandler.exe cmd /c \"$cmdChain & taskkill /F /IM explorer.exe & wineserver -k\"" + val exitFile = "C:\\$EXIT_CODE_FILE" + val wrapped = "winhandler.exe cmd /c \"($cmdChain && echo 0 > $exitFile || echo 1 > $exitFile) " + + "& taskkill /F /IM explorer.exe & wineserver -k\"" return "wine explorer /desktop=shell,$screenInfo $wrapped" } } From c442dfb44a5e9d1c2324d2b92a44a28ac418ae0d Mon Sep 17 00:00:00 2001 From: PlayDay <18056374+playday3008@users.noreply.github.com> Date: Thu, 21 May 2026 14:35:33 +0200 Subject: [PATCH 10/10] fix: skip UbisoftConnectStep for Steam games, handled by InstallScript --- .../utils/preInstallSteps/UbisoftConnectStep.kt | 3 ++- .../java/app/gamenative/utils/PreInstallStepsTest.kt | 12 ++++++------ .../utils/preInstallSteps/UbisoftConnectStepTest.kt | 7 ++++++- 3 files changed, 14 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/app/gamenative/utils/preInstallSteps/UbisoftConnectStep.kt b/app/src/main/java/app/gamenative/utils/preInstallSteps/UbisoftConnectStep.kt index 09b4054863..cf89efff29 100644 --- a/app/src/main/java/app/gamenative/utils/preInstallSteps/UbisoftConnectStep.kt +++ b/app/src/main/java/app/gamenative/utils/preInstallSteps/UbisoftConnectStep.kt @@ -19,7 +19,8 @@ object UbisoftConnectStep : PreInstallStep { gameSource: GameSource, gameDirPath: String, ): Boolean { - if (MarkerUtils.hasMarker(gameDirPath, Marker.UBISOFT_CONNECT_INSTALLED)) return false + if (gameSource == GameSource.STEAM) return false + if (MarkerUtils.hasMarker(gameDirPath, Marker.UBISOFT_CONNECT_INSTALLED)) return false return true } diff --git a/app/src/test/java/app/gamenative/utils/PreInstallStepsTest.kt b/app/src/test/java/app/gamenative/utils/PreInstallStepsTest.kt index aad754359c..dcfe27bf0a 100644 --- a/app/src/test/java/app/gamenative/utils/PreInstallStepsTest.kt +++ b/app/src/test/java/app/gamenative/utils/PreInstallStepsTest.kt @@ -71,8 +71,8 @@ class PreInstallStepsTest { val result = PreInstallSteps.getPreInstallCommands( container = container, - appId = "STEAM_400", - gameSource = GameSource.STEAM, + appId = "GOG_400", + gameSource = GameSource.GOG, screenInfo = "1280x720", containerVariantChanged = false, ) @@ -94,8 +94,8 @@ class PreInstallStepsTest { val withoutReset = PreInstallSteps.getPreInstallCommands( container = container, - appId = "STEAM_400", - gameSource = GameSource.STEAM, + appId = "GOG_400", + gameSource = GameSource.GOG, screenInfo = "1280x720", containerVariantChanged = false, ) @@ -103,8 +103,8 @@ class PreInstallStepsTest { val withReset = PreInstallSteps.getPreInstallCommands( container = container, - appId = "STEAM_400", - gameSource = GameSource.STEAM, + appId = "GOG_400", + gameSource = GameSource.GOG, screenInfo = "1280x720", containerVariantChanged = true, ) diff --git a/app/src/test/java/app/gamenative/utils/preInstallSteps/UbisoftConnectStepTest.kt b/app/src/test/java/app/gamenative/utils/preInstallSteps/UbisoftConnectStepTest.kt index 9d12e816d9..5daedaefca 100644 --- a/app/src/test/java/app/gamenative/utils/preInstallSteps/UbisoftConnectStepTest.kt +++ b/app/src/test/java/app/gamenative/utils/preInstallSteps/UbisoftConnectStepTest.kt @@ -29,12 +29,17 @@ class UbisoftConnectStepTest { @Test fun appliesTo_returnsFalse_whenMarkerExists() { MarkerUtils.addMarker(gameDir.absolutePath, Marker.UBISOFT_CONNECT_INSTALLED) + assertFalse(UbisoftConnectStep.appliesTo(container, GameSource.GOG, gameDir.absolutePath)) + } + + @Test + fun appliesTo_returnsFalse_forSteamGames() { assertFalse(UbisoftConnectStep.appliesTo(container, GameSource.STEAM, gameDir.absolutePath)) } @Test fun appliesTo_returnsTrue_whenMarkerMissing() { - assertTrue(UbisoftConnectStep.appliesTo(container, GameSource.STEAM, gameDir.absolutePath)) + assertTrue(UbisoftConnectStep.appliesTo(container, GameSource.GOG, gameDir.absolutePath)) } @Test