Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/main/java/app/gamenative/data/LaunchInfo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import kotlinx.serialization.Serializable
@Serializable
data class LaunchInfo(
val executable: String,
val arguments: String = "",
val workingDir: String,
val description: String,
val type: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ object GameFixesRegistry {
GOG_Fix_1787707874,
GOG_Fix_1808582759,
GOG_Fix_2147483047,
STEAM_Fix_400,
STEAM_Fix_22300,
STEAM_Fix_22330,
STEAM_Fix_22370,
Expand Down
13 changes: 0 additions & 13 deletions app/src/main/java/app/gamenative/gamefixes/STEAM_400.kt

This file was deleted.

7 changes: 5 additions & 2 deletions app/src/main/java/app/gamenative/service/SteamService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2269,8 +2269,11 @@ class SteamService : Service(), IChallengeUrlChanged {
fun getWindowsLaunchInfos(appId: Int): List<LaunchInfo> {
return getAppInfoOf(appId)?.let { appInfo ->
appInfo.config.launch.filter { launchInfo ->
// since configOS was unreliable and configArch was even more unreliable
launchInfo.executable.endsWith(".exe", ignoreCase = true)
val exe = launchInfo.executable
exe.endsWith(".exe", ignoreCase = true) ||
exe.endsWith(".bat", ignoreCase = true) ||
exe.endsWith(".cmd", ignoreCase = true) ||
exe.contains("://")
}
}.orEmpty()
}
Expand Down
1 change: 1 addition & 0 deletions app/src/main/java/app/gamenative/ui/PluviaMain.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1158,6 +1158,7 @@ fun PluviaMain(
visible = true,
title = context.getString(R.string.container_config_title),
initialConfig = config,
steamAppId = appId.removePrefix("STEAM_").toIntOrNull()?.takeIf { appId.startsWith("STEAM_") },
onDismissRequest = { openContainerConfigForAppId = null },
onSave = { newConfig ->
scope.launch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
Expand Down Expand Up @@ -83,6 +84,7 @@ import app.gamenative.ui.theme.PluviaTheme
import app.gamenative.ui.theme.settingsTileColors
import app.gamenative.ui.theme.settingsTileColorsAlt
import app.gamenative.utils.CustomGameScanner
import app.gamenative.data.LaunchInfo
import app.gamenative.utils.ContainerUtils
import app.gamenative.utils.ManifestComponentHelper
import app.gamenative.utils.ManifestContentTypes
Expand Down Expand Up @@ -143,6 +145,7 @@ fun ContainerConfigDialog(
default: Boolean = false,
title: String,
initialConfig: ContainerData = ContainerData(),
steamAppId: Int? = null,
onDismissRequest: () -> Unit,
onSave: (ContainerData) -> Unit,
) {
Expand Down Expand Up @@ -941,6 +944,7 @@ fun ContainerConfigDialog(

val state = ContainerConfigState(
config = configState,
steamAppId = steamAppId,
graphicsDrivers = graphicsDriversRef,
bionicWineEntries = bionicWineEntriesRef,
glibcWineEntries = glibcWineEntriesRef,
Expand Down Expand Up @@ -1230,39 +1234,56 @@ private fun Preview_ContainerConfigDialog() {
}
}

/**
* Editable dropdown for selecting executable paths from the container's A: drive
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun ExecutablePathDropdown(
modifier: Modifier = Modifier,
value: String,
onValueChange: (String) -> Unit,
onLaunchOptionSelected: (executablePath: String, execArgs: String?) -> Unit,
containerData: ContainerData,
steamAppId: Int? = null,
) {
Comment thread
playday3008 marked this conversation as resolved.
var expanded by remember { mutableStateOf(false) }
var executables by remember { mutableStateOf<List<String>>(emptyList()) }
var picsEntries by remember { mutableStateOf<List<LaunchInfo>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) }
val context = LocalContext.current

// Load executables from A: drive when component is first created
LaunchedEffect(containerData.drives) {
LaunchedEffect(containerData.drives, steamAppId) {
isLoading = true
executables = withContext(Dispatchers.IO) {
ContainerUtils.scanExecutablesInADrive(containerData.drives)
val (pics, scanned) = withContext(Dispatchers.IO) {
val pics = if (steamAppId != null) {
SteamService.getWindowsLaunchInfos(steamAppId)
} else {
emptyList()
}
val scanned = ContainerUtils.scanExecutablesInADrive(containerData.drives)
pics to scanned
}
picsEntries = pics
executables = scanned

if (pics.isNotEmpty()) {
val alreadyMatchesPics = pics.any {
it.executable == value && it.arguments == containerData.execArgs
}
val alreadyMatchesScanned = scanned.any { it == value }
if (!alreadyMatchesPics && !alreadyMatchesScanned) {
val defaultEntry = pics.firstOrNull { it.type == "default" } ?: pics.first()
onLaunchOptionSelected(defaultEntry.executable, defaultEntry.arguments)
}
}

isLoading = false
}

ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = it },
modifier = modifier
modifier = modifier,
) {
NoExtractOutlinedTextField(
value = value,
onValueChange = onValueChange,
onValueChange = { },
readOnly = true,
label = { Text(stringResource(R.string.container_config_executable_path)) },
placeholder = { Text(stringResource(R.string.container_config_executable_path_placeholder)) },
Expand All @@ -1276,35 +1297,70 @@ internal fun ExecutablePathDropdown(
modifier = Modifier
.fillMaxWidth()
.menuAnchor(),
singleLine = true
singleLine = true,
)

if (!isLoading && executables.isNotEmpty()) {
val hasContent = !isLoading && (picsEntries.isNotEmpty() || executables.isNotEmpty())
if (hasContent) {
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
onDismissRequest = { expanded = false },
) {
picsEntries.forEach { entry ->
DropdownMenuItem(
text = {
Column {
Text(
text = entry.description.ifEmpty { "Default" },
style = MaterialTheme.typography.bodyMedium,
)
Text(
text = entry.executable,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
if (entry.arguments.isNotEmpty()) {
Text(
text = entry.arguments,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
onClick = {
onLaunchOptionSelected(entry.executable, entry.arguments)
expanded = false
},
)
}

if (picsEntries.isNotEmpty() && executables.isNotEmpty()) {
HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
}

executables.forEach { executable ->
DropdownMenuItem(
text = {
Column {
Text(
text = executable.substringAfterLast('\\'),
style = MaterialTheme.typography.bodyMedium
text = executable.substringAfterLast('/').substringAfterLast('\\'),
style = MaterialTheme.typography.bodyMedium,
)
if (executable.contains('\\')) {
val parent = executable.substringBeforeLast('/').substringBeforeLast('\\')
if (parent != executable) {
Text(
text = executable.substringBeforeLast('\\'),
text = parent,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
},
onClick = {
onValueChange(executable)
onLaunchOptionSelected(executable, null)
expanded = false
}
},
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import com.winlator.fexcore.FEXCorePreset
*/
class ContainerConfigState(
val config: MutableState<ContainerData>,
val steamAppId: Int? = null,
val graphicsDrivers: MutableState<MutableList<String>>,
val bionicWineEntries: MutableState<List<String>>,
val glibcWineEntries: MutableState<List<String>>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -259,8 +259,14 @@ fun GeneralTabContent(
ExecutablePathDropdown(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
value = config.executablePath,
onValueChange = { state.config.value = config.copy(executablePath = it) },
onLaunchOptionSelected = { path, args ->
state.config.value = config.copy(
executablePath = path,
execArgs = args ?: config.execArgs,
Comment thread
playday3008 marked this conversation as resolved.
)
Comment thread
playday3008 marked this conversation as resolved.
},
containerData = config,
steamAppId = state.steamAppId,
)
NoExtractOutlinedTextField(
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1221,6 +1221,7 @@ abstract class BaseAppScreen {
ContainerConfigDialog(
title = "${displayInfo.name} Config",
initialConfig = containerData,
steamAppId = if (libraryItem.gameSource == GameSource.STEAM) libraryItem.gameId else null,
onDismissRequest = { showConfigDialog = false },
onSave = {
saveContainerConfig(context, libraryItem, it)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ private fun normalizeProcessName(name: String): String {

private fun extractExecutableBasename(path: String): String {
if (path.isBlank()) return ""
if (ContainerUtils.isUriScheme(path)) return ""
return normalizeProcessName(path)
}

Expand Down Expand Up @@ -1689,7 +1690,7 @@ fun XServerScreen(
if (!bootToContainer) {
renderer.setUnviewableWMClasses("explorer.exe")
// TODO: make 'force fullscreen' be an option of the app being launched
if (container.executablePath.isNotBlank()) {
if (container.executablePath.isNotBlank() && !ContainerUtils.isUriScheme(container.executablePath)) {
renderer.forceFullscreenWMClass = Paths.get(container.executablePath).name
}
}
Expand Down Expand Up @@ -3716,14 +3717,22 @@ private fun getWineStartCommand(
return "winhandler.exe \"wfm.exe\""
}

// Set working directory to the game folder
val executableDir = gameFolderPath + "/" + executablePath.substringBeforeLast("/", "")
// Set working directory to the game folder (URIs have no meaningful subdirectory)
val executableDir = if (ContainerUtils.isUriScheme(executablePath)) {
gameFolderPath!!
} else {
gameFolderPath + "/" + executablePath.substringBeforeLast("/", "")
}
guestProgramLauncherComponent.workingDir = File(executableDir)

// Normalize path separators (ensure Windows-style backslashes)
val normalizedPath = executablePath.replace('/', '\\')
envVars.put("WINEPATH", "A:\\")
"\"A:\\${normalizedPath}\""
when {
ContainerUtils.isUriScheme(executablePath) -> "cmd /c start /WAIT \"\" \"$executablePath\""
ContainerUtils.isBatchScript(executablePath) -> "cmd /c call \"A:\\${normalizedPath}\""
else -> "\"A:\\${normalizedPath}\""
Comment thread
playday3008 marked this conversation as resolved.
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} else if (container.executablePath.isEmpty()) {
// For Steam games, we need appLaunchInfo
Timber.tag("XServerScreen").w("appLaunchInfo is null for Steam game: $appId")
Expand Down Expand Up @@ -3756,11 +3765,15 @@ private fun getWineStartCommand(
}
if (container.isUseLegacyDRM) {
val appDirPath = SteamService.getAppDirPath(gameId)
val executableDir = appDirPath + "/" + executablePath.substringBeforeLast("/", "")
guestProgramLauncherComponent.workingDir = File(executableDir);
Timber.i("Working directory is ${executableDir}")
val executableDir = if (ContainerUtils.isUriScheme(executablePath)) {
appDirPath
} else {
appDirPath + "/" + executablePath.substringBeforeLast("/", "")
}
guestProgramLauncherComponent.workingDir = File(executableDir)
Timber.i("Working directory is $executableDir")

Timber.i("Final exe path is " + executablePath)
Timber.i("Final exe path is $executablePath")
val drives = container.drives
val driveIndex = drives.indexOf(appDirPath)
// greater than 1 since there is the drive character and the colon before the app dir path
Expand All @@ -3773,7 +3786,11 @@ private fun getWineStartCommand(
if (appLaunchInfo != null){
envVars.put("WINEPATH", "$drive:/${appLaunchInfo.workingDir}")
}
"\"$drive:/${executablePath}\""
when {
ContainerUtils.isUriScheme(executablePath) -> "cmd /c start /WAIT \"\" \"$executablePath\""
ContainerUtils.isBatchScript(executablePath) -> "cmd /c call \"$drive:/${executablePath}\""
else -> "\"$drive:/${executablePath}\""
}
} else {
"\"C:\\\\Program Files (x86)\\\\Steam\\\\steamclient_loader_x64.exe\""
}
Expand Down
26 changes: 13 additions & 13 deletions app/src/main/java/app/gamenative/utils/ContainerUtils.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1182,6 +1182,11 @@ object ContainerUtils {
return null
}

fun isUriScheme(path: String): Boolean = path.contains("://")

fun isBatchScript(path: String): Boolean =
path.endsWith(".bat", ignoreCase = true) || path.endsWith(".cmd", ignoreCase = true)

/**
* Scans the container's A: drive for all .exe files
*/
Expand Down Expand Up @@ -1213,7 +1218,9 @@ object ContainerUtils {
if (file.isDirectory) {
if (FileUtils.isSymlink(file)) return@forEach
scanRecursive(file, baseDir, depth + 1, maxDepth)
} else if (file.isFile && file.name.lowercase().endsWith(".exe")) {
} else if (file.isFile && file.name.lowercase().let { name ->
name.endsWith(".exe") || name.endsWith(".bat") || name.endsWith(".cmd")
}) {
// Convert to relative Windows path format
val relativePath = baseDir.toURI().relativize(file.toURI()).path
executables.add(relativePath)
Expand Down Expand Up @@ -1256,28 +1263,21 @@ object ContainerUtils {
* Assigns priority scores to executables for better sorting
*/
private fun getExecutablePriority(exePath: String): Int {
val fileName = exePath.substringAfterLast('\\').lowercase()
val fileName = exePath.substringAfterLast('\\').substringAfterLast('/').lowercase()
val baseName = fileName.substringBeforeLast('.')
val isBatch = fileName.endsWith(".bat") || fileName.endsWith(".cmd")

return when {
// Highest priority: common game executable patterns
val baseScore = when {
fileName.contains("game") -> 100

fileName.contains("start") -> 85

fileName.contains("main") -> 80

fileName.contains("launcher") && !fileName.contains("unins") -> 75

// High priority: probable main executables
baseName.length >= 4 && !isSystemExecutable(fileName) -> 70

// Medium priority: any non-system executable
!isSystemExecutable(fileName) -> 50

// Low priority: system/utility executables
else -> 10
}

return if (isBatch) (baseScore - 10).coerceAtLeast(5) else baseScore
}

/**
Expand Down
Loading