diff --git a/app/src/main/app/shell/LaunchOptionsScreen.kt b/app/src/main/app/shell/LaunchOptionsScreen.kt new file mode 100644 index 000000000..8309a381b --- /dev/null +++ b/app/src/main/app/shell/LaunchOptionsScreen.kt @@ -0,0 +1,251 @@ +package com.winlator.cmod.app.shell + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.RocketLaunch +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.winlator.cmod.R + +/** A single Steam launch option (appinfo `config.launch` entry). */ +internal data class StoreLaunchOptionItem( + // Relative path, '/'-separated. + val executable: String, + val arguments: String, + val label: String, +) + +// Palette — mirrors the Workshop window so the modal feels native. +private val LoBg = Color(0xFF12121B) +private val LoBorder = Color(0xFF2A2A3A) +private val LoAccent = Color(0xFF1A9FFF) +private val LoAccentGlow = Color(0xFF58A6FF) +private val LoTextPrimary = Color(0xFFF0F4FF) +private val LoTextSecondary = Color(0xFF93A6BC) +private val LoScrim = Color(0xFF000000) + +/** + * Steam launch-option picker — a Workshop-shaped modal window listing the + * game's appinfo `config.launch` entries. Tapping a row persists it as the + * game's default; the check mark moves to confirm and the window stays open. + * + * Stateless: data and callbacks are hoisted to the LaunchOptionsDialog wrapper. + */ +@Composable +internal fun StoreLaunchOptionsScreen( + gameTitle: String, + options: List, + selectedOption: StoreLaunchOptionItem?, + onSelect: (StoreLaunchOptionItem) -> Unit, + onClose: () -> Unit, +) { + BoxWithConstraints( + modifier = + Modifier + .fillMaxSize() + // Dim the game-detail screen behind so the modal reads as foreground. + .background(LoScrim.copy(alpha = 0.6f)) + .windowInsetsPadding(WindowInsets.navigationBars), + contentAlignment = Alignment.Center, + ) { + val dialogWidth = (maxWidth - 32.dp).coerceAtMost(560.dp) + val dialogMaxHeight = (maxHeight - 48.dp).coerceIn(220.dp, 640.dp) + Surface( + modifier = + Modifier + .widthIn(min = 320.dp, max = dialogWidth) + .fillMaxWidth() + .heightIn(max = dialogMaxHeight), + shape = RoundedCornerShape(14.dp), + color = LoBg, + border = BorderStroke(1.dp, LoBorder), + tonalElevation = 8.dp, + ) { + Column(Modifier.fillMaxWidth()) { + LaunchOptionsHeader( + gameTitle = gameTitle, + optionCount = options.size, + onClose = onClose, + ) + HorizontalDivider(color = LoBorder, thickness = 0.5.dp) + LazyColumn( + // fill = false: the window wraps short lists instead of + // stretching to the max height. + modifier = Modifier.fillMaxWidth().weight(1f, fill = false), + contentPadding = PaddingValues(vertical = 4.dp), + ) { + itemsIndexed(options) { index, option -> + LaunchOptionPickerRow( + option = option, + selected = option == selectedOption, + onClick = { onSelect(option) }, + ) + if (index < options.lastIndex) { + HorizontalDivider( + color = Color.White.copy(alpha = 0.06f), + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 14.dp), + ) + } + } + } + } + } + } +} + +@Composable +private fun LaunchOptionsHeader( + gameTitle: String, + optionCount: Int, + onClose: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + Modifier + .size(34.dp) + .clip(RoundedCornerShape(9.dp)) + .background(LoAccent.copy(alpha = 0.16f)), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Outlined.RocketLaunch, + contentDescription = null, + tint = LoAccentGlow, + modifier = Modifier.size(19.dp), + ) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + stringResource(R.string.store_game_launch_options).uppercase(), + color = LoTextSecondary, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.9.sp, + ) + Text( + gameTitle, + style = MaterialTheme.typography.titleSmall, + color = LoTextPrimary, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Surface( + modifier = + Modifier.semantics { + contentDescription = "$optionCount launch options" + }, + color = LoAccent.copy(alpha = 0.14f), + shape = RoundedCornerShape(7.dp), + ) { + Text( + optionCount.toString(), + color = LoAccentGlow, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 9.dp, vertical = 3.dp), + ) + } + IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) { + Icon( + Icons.Outlined.Close, + contentDescription = "Close", + tint = LoTextSecondary, + modifier = Modifier.size(20.dp), + ) + } + } +} + +@Composable +private fun LaunchOptionPickerRow( + option: StoreLaunchOptionItem, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(11.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + option.label, + color = if (selected) LoAccentGlow else LoTextPrimary, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + buildString { + append(option.executable) + if (option.arguments.isNotBlank()) { + append(" · ") + append(option.arguments) + } + }, + color = LoTextSecondary, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (selected) { + Icon( + Icons.Outlined.Check, + contentDescription = null, + tint = LoAccentGlow, + modifier = Modifier.size(18.dp), + ) + } + } +} diff --git a/app/src/main/app/shell/LibraryGameLaunchScreen.kt b/app/src/main/app/shell/LibraryGameLaunchScreen.kt index 9f1bac0b2..11f0c40b2 100644 --- a/app/src/main/app/shell/LibraryGameLaunchScreen.kt +++ b/app/src/main/app/shell/LibraryGameLaunchScreen.kt @@ -49,6 +49,7 @@ import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.DesktopWindows import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.RocketLaunch import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.Save @@ -133,6 +134,8 @@ internal fun LibraryGameLaunchScreen( showVerifyFiles: Boolean = true, showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, + showLaunchOptions: Boolean = false, + onLaunchOptions: () -> Unit = {}, playEnabled: Boolean = true, playDisabledLabel: String? = null, onBack: () -> Unit, @@ -268,10 +271,12 @@ internal fun LibraryGameLaunchScreen( showVerifyFiles = showVerifyFiles, showCheckForUpdate = showCheckForUpdate, showWorkshop = showWorkshop, + showLaunchOptions = showLaunchOptions, areSteamActionsEnabled = areSteamActionsEnabled, onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, + onLaunchOptions = onLaunchOptions, ) } @@ -803,10 +808,12 @@ private fun SourceTag( showVerifyFiles: Boolean = true, showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, + showLaunchOptions: Boolean = false, areSteamActionsEnabled: Boolean = true, onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, + onLaunchOptions: () -> Unit = {}, ) { var menuOpen by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableStateOf(0) } @@ -877,6 +884,13 @@ private fun SourceTag( enabled = areSteamActionsEnabled, ) { menuOpen = false; onWorkshop() } } + if (showLaunchOptions) { + LaunchSourceMenuItem( + icon = Icons.Outlined.RocketLaunch, + label = stringResource(R.string.store_game_launch_options), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onLaunchOptions() } + } } } } diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt index 64b4fbd8e..f936b84a4 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -57,6 +57,7 @@ import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material.icons.outlined.Extension import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.RocketLaunch import androidx.compose.material.icons.outlined.SportsEsports import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.SystemUpdate @@ -149,6 +150,8 @@ internal fun StoreGameDetailScreen( showWorkshop: Boolean = false, showVerifyFiles: Boolean = false, areSteamActionsEnabled: Boolean = true, + showLaunchOptions: Boolean = false, + onLaunchOptions: () -> Unit = {}, dlcs: List = emptyList(), selectedDlcIds: Set = emptySet(), isDlcSelectionEnabled: Boolean = true, @@ -186,7 +189,9 @@ internal fun StoreGameDetailScreen( val showUpdateCta = updateCheckAvailable && isUpdateAvailable val verifyFilesAvailable = showVerifyFiles && isInstalled val workshopAvailable = showWorkshop && isInstalled - val sourceMenuEnabled = updateCheckAvailable || verifyFilesAvailable || workshopAvailable + val launchOptionsAvailable = showLaunchOptions && isInstalled + val sourceMenuEnabled = + updateCheckAvailable || verifyFilesAvailable || workshopAvailable || launchOptionsAvailable val showDlcCard = dlcs.isNotEmpty() val showActionColumn = showDownloadCta || showUpdateCta || @@ -293,6 +298,7 @@ internal fun StoreGameDetailScreen( showCheckForUpdate = updateCheckAvailable, showVerifyFiles = verifyFilesAvailable, showWorkshop = workshopAvailable, + showLaunchOptions = launchOptionsAvailable, isCheckingForUpdate = isCheckingForUpdate, areSteamActionsEnabled = areSteamActionsEnabled, isUpdateCheckEnabled = @@ -303,6 +309,7 @@ internal fun StoreGameDetailScreen( onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, + onLaunchOptions = onLaunchOptions, ) } @@ -780,12 +787,14 @@ private fun StoreSourceTag( showCheckForUpdate: Boolean = false, showVerifyFiles: Boolean = false, showWorkshop: Boolean = false, + showLaunchOptions: Boolean = false, isCheckingForUpdate: Boolean = false, areSteamActionsEnabled: Boolean = true, isUpdateCheckEnabled: Boolean = true, onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, + onLaunchOptions: () -> Unit = {}, ) { var menuOpen by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableIntStateOf(0) } @@ -861,6 +870,13 @@ private fun StoreSourceTag( enabled = areSteamActionsEnabled, ) { menuOpen = false; onWorkshop() } } + if (showLaunchOptions) { + StoreSourceMenuItem( + icon = Icons.Outlined.RocketLaunch, + label = stringResource(R.string.store_game_launch_options), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onLaunchOptions() } + } } } } diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 74dc19685..eadcc40d5 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -218,6 +218,7 @@ import com.winlator.cmod.shared.theme.WinNativeTheme import dagger.hilt.android.AndroidEntryPoint import dagger.Lazy import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -4676,6 +4677,24 @@ class UnifiedActivity : val isEpic = app.id >= 2000000000 val isGog = gogGame != null val epicId = if (isEpic) app.id - 2000000000 else 0 + val isSteamLibraryGame = !isCustom && !isEpic && !isGog + + var showLaunchOptionsDialog by remember(app.id) { mutableStateOf(false) } + var launchOptions by remember(app.id) { mutableStateOf>(emptyList()) } + var selectedLaunchOption by remember(app.id) { mutableStateOf(null) } + LaunchedEffect(app.id, isSteamLibraryGame) { + val steamInstalled = + isSteamLibraryGame && withContext(Dispatchers.IO) { SteamService.isAppInstalled(app.id) } + if (!steamInstalled) { + launchOptions = emptyList() + selectedLaunchOption = null + return@LaunchedEffect + } + loadSteamLaunchOptionsRefreshing(app.id) { options, selected -> + launchOptions = options + selectedLaunchOption = selected + } + } val libraryDownloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState( initial = com.winlator.cmod.app.service.download.DownloadCoordinator.snapshotRecords(), @@ -5404,6 +5423,8 @@ class UnifiedActivity : (!isEpic || epicGame?.isInstalled == true) && (!isGog || gogGame?.isInstalled == true), showWorkshop = !isEpic && !isGog, + showLaunchOptions = launchOptions.size >= 2, + onLaunchOptions = { showLaunchOptionsDialog = true }, areSteamActionsEnabled = when { isEpic -> !hasBlockingEpicDownloadForLibrary @@ -5884,6 +5905,17 @@ class UnifiedActivity : onDismissRequest = { showWorkshopDialog = false }, ) } + + if (showLaunchOptionsDialog) { + LaunchOptionsDialog( + appId = app.id, + gameTitle = app.name, + options = launchOptions, + selectedOption = selectedLaunchOption, + onSelectionSaved = { selectedLaunchOption = it }, + onDismissRequest = { showLaunchOptionsDialog = false }, + ) + } } } } @@ -8873,6 +8905,95 @@ class UnifiedActivity : } } + // Apps whose appinfo was already re-fetched this process (see + // loadSteamLaunchOptionsRefreshing) — avoids a PICS round-trip on every + // game-detail open. + private val launchOptionsRefreshedApps = java.util.Collections.synchronizedSet(mutableSetOf()) + + /** + * Builds the Steam launch-option list (appinfo config.launch) for the STEAM + * dropdown on both game detail screens, plus the currently effective selection. + */ + private suspend fun loadSteamLaunchOptions(appId: Int): Pair, StoreLaunchOptionItem?> = + withContext(Dispatchers.IO) { + val appDir = java.io.File(SteamService.getAppDirPath(appId)) + val allOptions = + SteamService + .getWindowsLaunchInfos(appId) + .map { info -> + StoreLaunchOptionItem( + executable = info.executable, + arguments = info.arguments, + label = info.description.ifBlank { info.executable.substringAfterLast('/') }, + ) + } + // Label is part of the key: cached appinfo rows predating + // LaunchInfo.arguments have "" args, and exe+args alone would + // collapse distinct options like "Play (DX11)" / "Play (DX12)". + .distinctBy { Triple(it.executable.lowercase(), it.arguments, it.label) } + // Hide options whose exe is missing on disk, but if that empties a + // non-empty list (case-sensitivity quirks) keep the unfiltered set. + val onDisk = allOptions.filter { java.io.File(appDir, it.executable).isFile } + val options = onDisk.ifEmpty { allOptions } + val (selectedExe, selectedArgs) = SteamService.getSelectedLaunchOption(applicationContext, appId) + val selected = + options.firstOrNull { + it.executable.equals(selectedExe, ignoreCase = true) && it.arguments == selectedArgs + } ?: options.firstOrNull { it.executable.equals(selectedExe, ignoreCase = true) } + ?: options.firstOrNull() + options to selected + } + + /** + * Loads launch options, then — once per app per process — re-fetches the + * app's PICS appinfo to heal cached rows that predate LaunchInfo.arguments + * and re-applies the fresh list. [apply] runs on the caller's context. + */ + private suspend fun loadSteamLaunchOptionsRefreshing( + appId: Int, + apply: (List, StoreLaunchOptionItem?) -> Unit, + ) { + val (options, selected) = loadSteamLaunchOptions(appId) + apply(options, selected) + if (launchOptionsRefreshedApps.add(appId)) { + if (SteamService.refreshAppInfoFromPics(appId)) { + val (fresh, freshSelected) = loadSteamLaunchOptions(appId) + apply(fresh, freshSelected) + } else { + // Offline or fetch failed — allow a retry on the next open. + launchOptionsRefreshedApps.remove(appId) + } + } + } + + private fun persistSteamLaunchOptionSelection( + appId: Int, + option: StoreLaunchOptionItem, + scope: CoroutineScope, + onSaved: (StoreLaunchOptionItem) -> Unit, + ) { + scope.launch(Dispatchers.IO) { + val saved = + SteamService.setSelectedLaunchOption( + applicationContext, + appId, + option.executable, + option.arguments, + ) + withContext(Dispatchers.Main) { + if (saved) { + onSaved(option) + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + this@UnifiedActivity, + getString(R.string.store_game_launch_option_failed), + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } + } + // Game Manager Dialog @Composable fun GameManagerDialog( @@ -8892,6 +9013,9 @@ class UnifiedActivity : var isCheckingForUpdate by remember(app.id) { mutableStateOf(false) } var isUpdateCheckCoolingDown by remember(app.id) { mutableStateOf(false) } var showWorkshopDialog by remember(app.id) { mutableStateOf(false) } + var showLaunchOptionsDialog by remember(app.id) { mutableStateOf(false) } + var launchOptions by remember(app.id) { mutableStateOf>(emptyList()) } + var selectedLaunchOption by remember(app.id) { mutableStateOf(null) } var updateInfo by remember(app.id) { mutableStateOf(null) } var updateStatusText by remember(app.id) { mutableStateOf(null) } val downloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState( @@ -8957,6 +9081,18 @@ class UnifiedActivity : } } + LaunchedEffect(app.id, installed) { + if (installed != true) { + launchOptions = emptyList() + selectedLaunchOption = null + return@LaunchedEffect + } + loadSteamLaunchOptionsRefreshing(app.id) { options, selected -> + launchOptions = options + selectedLaunchOption = selected + } + } + val totalDownloadSize = selectedManifestSizes.downloadSize val totalInstallSize = selectedManifestSizes.installSize val defaultPathSet = @@ -9077,6 +9213,8 @@ class UnifiedActivity : showWorkshop = isReallyInstalled, showVerifyFiles = isReallyInstalled, areSteamActionsEnabled = !hasBlockingSteamDownload, + showLaunchOptions = launchOptions.size >= 2, + onLaunchOptions = { showLaunchOptionsDialog = true }, dlcs = dlcItems, selectedDlcIds = selectedDlcIds.toSet(), isDlcSelectionEnabled = steamDownloadRecord == null, @@ -9215,6 +9353,52 @@ class UnifiedActivity : onDismissRequest = { showWorkshopDialog = false }, ) } + + if (showLaunchOptionsDialog) { + LaunchOptionsDialog( + appId = app.id, + gameTitle = app.name, + options = launchOptions, + selectedOption = selectedLaunchOption, + onSelectionSaved = { selectedLaunchOption = it }, + onDismissRequest = { showLaunchOptionsDialog = false }, + ) + } + } + + /** + * Hosts the Workshop-styled launch-option picker window over a game detail + * dialog. Selecting a row persists it as the game's default and reports the + * saved option through [onSelectionSaved]. + */ + @Composable + private fun LaunchOptionsDialog( + appId: Int, + gameTitle: String, + options: List, + selectedOption: StoreLaunchOptionItem?, + onSelectionSaved: (StoreLaunchOptionItem) -> Unit, + onDismissRequest: () -> Unit, + ) { + val scope = rememberCoroutineScope() + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + StoreLaunchOptionsScreen( + gameTitle = gameTitle, + options = options, + selectedOption = selectedOption, + onSelect = { option -> + persistSteamLaunchOptionSelection(appId, option, scope, onSelectionSaved) + }, + onClose = onDismissRequest, + ) + } } @Composable diff --git a/app/src/main/feature/stores/steam/data/LaunchInfo.kt b/app/src/main/feature/stores/steam/data/LaunchInfo.kt index 75d747ae1..caf3ec823 100644 --- a/app/src/main/feature/stores/steam/data/LaunchInfo.kt +++ b/app/src/main/feature/stores/steam/data/LaunchInfo.kt @@ -11,6 +11,8 @@ data class LaunchInfo( val workingDir: String, val description: String, val type: String, + // Default keeps already-cached appinfo JSON (without this key) decodable. + val arguments: String = "", @Serializable(with = OsEnumSetSerializer::class) val configOS: java.util.EnumSet, val configArch: OSArch, diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 77d3c838b..6592f9793 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -94,6 +94,7 @@ import com.winlator.cmod.runtime.system.SessionKeepAliveService import com.winlator.cmod.shared.android.AppTerminationHelper import com.winlator.cmod.shared.ui.toast.WinToast import com.winlator.cmod.shared.android.NotificationHelper +import com.winlator.cmod.shared.io.FileUtils import com.winlator.cmod.shared.io.StorageUtils import dagger.hilt.android.AndroidEntryPoint import com.winlator.cmod.feature.stores.steam.enums.EDepotFileFlag @@ -2313,6 +2314,122 @@ class SteamService : Service() { return container.executablePath.ifEmpty { getInstalledExe(gameId) } } + private fun findSteamShortcut( + context: Context, + appId: Int, + ) = ContainerManager(context).loadShortcuts().find { + it.getExtra("game_source") == "STEAM" && it.getExtra("app_id") == appId.toString() + } + + /** + * Lightweight read of the Steam shortcut's `[Extra Data]` section straight + * from its .desktop file, with the owning container's root dir. Deliberately + * NOT findSteamShortcut/loadShortcuts on read-only paths: the Shortcut + * constructor decodes cover-art bitmaps, runs PE icon extraction and can + * rewrite .desktop files — far too heavy (and write-racy) for hot paths + * that only need a couple of strings. Returns null when no Steam shortcut + * exists for [appId]. + */ + private fun readSteamShortcutExtras( + context: Context, + appId: Int, + ): Pair, File>? { + val appIdStr = appId.toString() + val homeDir = File(ImageFs.find(context).rootDir, "home") + val containerDirs = + homeDir.listFiles { f -> f.isDirectory && f.name.startsWith("${ImageFs.USER}-") } + ?: return null + for (containerDir in containerDirs) { + val desktopDir = File(containerDir, ".wine/drive_c/users/${ImageFs.USER}/Desktop") + val files = desktopDir.listFiles { f -> f.name.endsWith(".desktop") } ?: continue + for (file in files) { + var section = "" + val extras = mutableMapOf() + // FileUtils.readLines (also used by Shortcut's parser) returns + // what it could read on IO errors — one corrupt .desktop file + // must not abort the scan of the remaining containers. + for (raw in FileUtils.readLines(file)) { + val line = raw.trim() + if (line.isEmpty() || line.startsWith("#")) continue + if (line.startsWith("[")) { + section = line.substringAfter("[").substringBefore("]") + continue + } + if (section != "Extra Data") continue + val key = line.substringBefore("=", "") + if (key.isNotEmpty()) extras[key] = line.substringAfter("=") + } + if (extras["game_source"] == "STEAM" && extras["app_id"] == appIdStr) { + return extras to containerDir + } + } + } + return null + } + + /** Reads executablePath from a container's `.container` config without Container construction. */ + private fun readContainerExecutablePath(containerDir: File): String = + runCatching { + val configFile = File(containerDir, ".container") + if (!configFile.isFile) return "" + JSONObject(configFile.readText()).optString("executablePath", "") + }.getOrElse { "" } + + /** + * Persists the user's launch-option choice (an appinfo `config.launch` entry) on + * the game's shortcut + container, matching resolveRelativeGameExe's priority: + * shortcut `launch_exe_path` first, container.executablePath as the synced + * fallback. The option's own arguments go to `launch_exe_args`, kept separate + * from the user-editable custom args (`execArgs`). Call on an IO dispatcher. + */ + fun setSelectedLaunchOption( + context: Context, + appId: Int, + executable: String, + arguments: String, + ): Boolean { + var shortcut = findSteamShortcut(context, appId) + if (shortcut == null) { + // Installs from older builds may predate shortcut creation on download. + createSteamShortcut(context, appId) + shortcut = findSteamShortcut(context, appId) + } + if (shortcut == null) { + Timber.w("setSelectedLaunchOption: no shortcut for appId=$appId") + return false + } + shortcut.putExtra("launch_exe_path", executable) + shortcut.putExtra("launch_exe_args", arguments.ifBlank { null }) + shortcut.saveData() + shortcut.container?.let { + it.executablePath = executable + it.saveData() + } + return true + } + + /** + * Currently effective launch option as (executable, arguments), resolved in the + * same order the launch path uses: shortcut `launch_exe_path` first, the owning + * container's executablePath as fallback, then the installed exe. Reads the + * .desktop/.container files directly (this runs on every game-detail open). + * Call on an IO dispatcher. + */ + fun getSelectedLaunchOption( + context: Context, + appId: Int, + ): Pair { + val found = runCatching { readSteamShortcutExtras(context, appId) }.getOrNull() + val extras = found?.first.orEmpty() + val exe = + extras["launch_exe_path"].orEmpty().ifBlank { + found?.second?.let { readContainerExecutablePath(it) }.orEmpty().ifBlank { + getInstalledExe(appId) + } + } + return exe.replace('\\', '/') to extras["launch_exe_args"].orEmpty() + } + suspend fun deleteApp(appId: Int): Boolean = withContext(Dispatchers.IO) { val appDirPath = getAppDirPath(appId) @@ -7668,6 +7785,23 @@ class SteamService : Service() { return null } + /** + * Best-effort re-fetch + persist of one app's PICS appinfo. Used to heal + * cached rows that predate newly parsed fields (e.g. LaunchInfo.arguments). + * Returns false when offline / fetch fails; callers keep using the cached row. + */ + suspend fun refreshAppInfoFromPics(appId: Int): Boolean { + val fresh = + try { + fetchLatestSteamAppInfo(appId) + } catch (e: Exception) { + Timber.w(e, "refreshAppInfoFromPics failed for appId=$appId") + null + } ?: return false + persistLatestSteamAppInfo(appId, fresh) + return true + } + private suspend fun persistLatestSteamAppInfo( appId: Int, remoteSteamApp: SteamApp, diff --git a/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt b/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt index bdb6d3d23..aa17d1d84 100644 --- a/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt +++ b/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt @@ -199,6 +199,7 @@ fun WnKeyValue.generateSteamApp(): SteamApp = workingDir = it["workingdir"].value?.replace('\\', '/').orEmpty(), description = it["description"].value.orEmpty(), type = it["type"].value.orEmpty(), + arguments = it["arguments"].value.orEmpty(), configOS = OS.from(it["config"]["oslist"].value), configArch = OSArch.from(it["config"]["osarch"].value), ) diff --git a/app/src/main/feature/stores/steam/utils/SteamUtils.kt b/app/src/main/feature/stores/steam/utils/SteamUtils.kt index 0b022ffa0..acde7feec 100644 --- a/app/src/main/feature/stores/steam/utils/SteamUtils.kt +++ b/app/src/main/feature/stores/steam/utils/SteamUtils.kt @@ -1399,15 +1399,39 @@ object SteamUtils { } } + /** + * Combines a launch option's own arguments (appinfo `config.launch` entry, persisted + * as the shortcut's `launch_exe_args` extra) with the user's custom args. Honors + * Steam's `%command%` placeholder in the custom args: the option args stay attached + * to the command itself so a user wrapper like `FOO=1 %command% -windowed` keeps + * working. + */ + @JvmStatic + fun combineSteamLaunchArgs( + selectedArgs: String?, + customArgs: String?, + ): String { + val selected = selectedArgs.orEmpty().trim() + val custom = customArgs.orEmpty().trim() + if (selected.isEmpty()) return custom + if (custom.isEmpty()) return selected + return if (custom.contains("%command%")) { + custom.replace("%command%", "%command% $selected") + } else { + "$selected $custom" + } + } + @JvmStatic fun updateOrModifyLocalConfig( imageFs: ImageFs, container: Container, appId: String, steamUserId64: String, + selectedLaunchArgs: String = "", ) { try { - val exeCommandLine = container.execArgs + val exeCommandLine = combineSteamLaunchArgs(selectedLaunchArgs, container.execArgs) com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient .setLaunchCommandLine(exeCommandLine) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 90fbbddb2..701db6b3f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -116,6 +116,8 @@ Update check failed Update Workshop + Launch Options + Couldn\'t save launch option Verify Files Verifying %1$s — check the Downloads tab Steam options diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index d6e9ed01c..94206b528 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -7523,8 +7523,9 @@ private String getWineStartCommand(GuestProgramLauncherComponent launcherCompone int appId = Integer.parseInt(shortcut.getExtra("app_id")); // Reset per launch; set below once the launch exe is resolved. wnSteamDirectExeOverride = false; - String steamExtraArgs = shortcut.getSettingExtra("execArgs", container.getExecArgs()); - steamExtraArgs = (steamExtraArgs != null && !steamExtraArgs.isEmpty()) ? " " + steamExtraArgs : ""; + String steamExtraArgs = + combineWithSelectedLaunchArgs(shortcut.getSettingExtra("execArgs", container.getExecArgs())); + steamExtraArgs = !steamExtraArgs.isEmpty() ? " " + steamExtraArgs : ""; boolean useColdClient = parseBoolean(getShortcutSetting("useColdClient", container.isUseColdClient() ? "1" : "0")); boolean launchBionicSteam = isBionicSteamEnabledForShortcut(); @@ -7910,7 +7911,7 @@ private void writeColdClientIniDirect(int appId, String gameDirName, String rela } String perGameExecArgs = shortcut != null ? shortcut.getSettingExtra("execArgs", container.getExecArgs()) : container.getExecArgs(); - String exeCommandLine = perGameExecArgs != null ? perGameExecArgs : ""; + String exeCommandLine = combineWithSelectedLaunchArgs(perGameExecArgs); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); @@ -7986,7 +7987,7 @@ private void writeColdClientIniForLaunch(int appId, String gameInstallPath, Stri } String perGameExecArgs = shortcut != null ? shortcut.getSettingExtra("execArgs", container.getExecArgs()) : container.getExecArgs(); - String exeCommandLine = perGameExecArgs != null ? perGameExecArgs : ""; + String exeCommandLine = combineWithSelectedLaunchArgs(perGameExecArgs); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); @@ -8111,6 +8112,16 @@ private static String exeBaseName(String path) { return normalized.trim(); } + /** + * The selected Steam launch option's own arguments (shortcut extra + * {@code launch_exe_args}) merged with the given custom args — the single + * resolver every Steam launch channel uses for its effective command line. + */ + private String combineWithSelectedLaunchArgs(String customArgs) { + return SteamUtils.combineSteamLaunchArgs( + shortcut != null ? shortcut.getExtra("launch_exe_args") : "", customArgs); + } + private String resolveRelativeGameExe(int appId, String gameInstPath) { // Per-game launch_exe_path wins over the shared container cache. String shortcutExePath = resolveShortcutSteamExecutablePath(gameInstPath); @@ -9663,10 +9674,17 @@ private void setupSteamEnvironment(int appId, File gameDir) { // Stamp-cache the registry edits + userdata reconcile + local-config // edit so warm launches of the same game in the same container skip // the per-launch file-copy / VDF-parse work. Stamp key is - // appId|userDataId — change either and the work re-runs. + // appId|userDataId|launchOptionsHash — change any and the work re-runs. File steamEnvStamp = new File(winePrefix, ".wine/drive_c/.wn-steamenv-" + appId + "-" + steamUserDataId + ".stamp"); - String expectedStamp = "v1|" + appId + "|" + steamUserDataId; + String selectedLaunchArgs = shortcut != null ? shortcut.getExtra("launch_exe_args") : ""; + // The effective LaunchOptions line is part of the stamp so a changed + // launch-option selection (or custom args) re-runs the localconfig edit. + // Raw string, not a hash: equality must be exact (no collisions). + String effectiveLaunchOptions = + SteamUtils.combineSteamLaunchArgs(selectedLaunchArgs, container.getExecArgs()); + String expectedStamp = "v2|" + appId + "|" + steamUserDataId + + "|" + effectiveLaunchOptions; String existingStamp = steamEnvStamp.exists() ? FileUtils.readString(steamEnvStamp).trim() : ""; boolean steamEnvWarm = expectedStamp.equals(existingStamp); @@ -9681,7 +9699,8 @@ private void setupSteamEnvironment(int appId, File gameDir) { skipFirstTimeSteamSetup(winePrefix); reconcileSteamUserdata(steamDir, steamUserDataId, steamId64); - SteamUtils.updateOrModifyLocalConfig(imageFs, container, String.valueOf(appId), steamUserDataId); + SteamUtils.updateOrModifyLocalConfig(imageFs, container, String.valueOf(appId), steamUserDataId, + selectedLaunchArgs); setupLightweightSteamConfig(steamDir, steamUserDataId); try { @@ -9691,6 +9710,10 @@ private void setupSteamEnvironment(int appId, File gameDir) { "Failed to write steam-env stamp at " + steamEnvStamp.getPath(), e); } } else { + // localconfig.vdf is already up to date, but the pushed launch command + // line is per-process native state — re-push it on warm launches too. + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient.INSTANCE + .setLaunchCommandLine(effectiveLaunchOptions); Log.d("XServerDisplayActivity", "Steam env warm-cache hit (appId=" + appId + ", userId=" + steamUserDataId + ") — skipping reconcile + autoLogin");