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/ui/screen/xserver/XServerScreen.kt b/app/src/main/java/app/gamenative/ui/screen/xserver/XServerScreen.kt index d21f5d4240..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 @@ -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,40 @@ 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, + appId = numericGameId, + ) + 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 +3126,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 +3169,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 +3248,31 @@ 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) { + 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", + ) + } + } + } + } + val allChainedCommands = chainedPreInstall + chainedInstallScript + + fun chainCommands(remaining: List) { if (remaining.isEmpty()) { guestProgramLauncherComponent.setGuestExecutable(gameExecutable) guestProgramLauncherComponent.setTerminationCallback(gameTerminationCallback) @@ -3219,13 +3280,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 +3293,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) } 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 { 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/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(), +) 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..51fd7d9504 --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/installscript/InstallScriptExecutor.kt @@ -0,0 +1,244 @@ +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 { + + private const val CLAIMED_OS_TYPE = "10" + private const val EXIT_CODE_FILE = "installscript_exit" + + data class RunProcessCommand( + val executable: String, + val hasRunKey: String?, + ) + + fun collectScripts( + steamApp: SteamApp, + appInfo: AppInfo, + gameDir: File, + installDir: String, + language: String, + appId: Int, + ): List { + val appDirPath = SteamService.getAppDirPath(appId) + val downloadedDepotIds = appInfo.downloadedDepots.toSet() + 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") + } + } + } + + 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) { + 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 commands = mutableListOf() + + for (script in scripts) { + for (action in script.runProcessActions) { + if (!matchesOS(action.requirementOS, is64Bit)) 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}" + } else { + action.process + } + val wrapped = wrapAsGuestExecutable(cmdLine, screenInfo) + + commands.add(RunProcessCommand(wrapped, effectiveHasRunKey)) + } + } + return commands + } + + fun markRunProcessComplete(container: Container, hasRunKey: String) { + val regFile = resolveRegFile(container, hasRunKey) + if (!regFile.exists()) return + try { + WineRegistryEditor(regFile).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 { + 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 + } + + 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(regFile).use { editor -> + val keyPath = hasRunKey.substringBeforeLast("\\") + val valueName = hasRunKey.substringAfterLast("\\") + val currentValue = editor.getDwordValue( + stripHivePrefix(keyPath), valueName, 0, + ) ?: 0 + currentValue >= maxOf(1, 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") + } + } + + 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 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" + } +} 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..77efbed859 --- /dev/null +++ b/app/src/main/java/app/gamenative/utils/installscript/InstallScriptParser.kt @@ -0,0 +1,220 @@ +package app.gamenative.utils.installscript + +import com.winlator.xenvironment.ImageFs +import `in`.dragonbra.javasteam.types.KeyValue +import java.io.File +import timber.log.Timber + +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.lowercase()] = 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/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/installscript/InstallScriptExecutorTest.kt b/app/src/test/java/app/gamenative/utils/installscript/InstallScriptExecutorTest.kt new file mode 100644 index 0000000000..a3befb04aa --- /dev/null +++ b/app/src/test/java/app/gamenative/utils/installscript/InstallScriptExecutorTest.kt @@ -0,0 +1,105 @@ +package app.gamenative.utils.installscript + +import org.junit.Assert.* +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class InstallScriptExecutorTest { + + // --- 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"), + ) + } +} 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()) + } +} 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