From af24f140b78fe1cd00c47c0cdc8d206337e83284 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 23:24:56 +0000 Subject: [PATCH 1/5] feat(steam): Launch Options selector in the STEAM dropdown Adds a "Launch Options" item to the STEAM menu on the game detail screen that lists the game's appinfo config.launch entries (e.g. ARK's "Launch ARK (No BattlEye)") with a checkmark on the current choice. Selecting one persists as the game's default: - exe -> shortcut extra launch_exe_path + container.executablePath (the priority resolveRelativeGameExe already honors; non-default exes ride the existing WN_STEAM_DIRECT_EXE override) - the option's own arguments -> new shortcut extra launch_exe_args, kept separate from the user's custom execArgs LaunchInfo gains an `arguments` field (defaulted, so cached appinfo JSON stays decodable) parsed from appinfo config/launch/arguments. A shared SteamUtils.combineSteamLaunchArgs joins option args with custom args across all three launch channels (direct command line, ColdClientLoader.ini ExeCommandLine, localconfig.vdf LaunchOptions / GetLaunchCommandLine). It honors Steam's %command% placeholder in custom args. The steam-env warm stamp now includes the effective LaunchOptions line so a changed selection re-runs the localconfig edit, and warm launches re-push the per-process launch command line. The submenu reuses the existing StoreSourceActionPopup surface and StoreSourceMenuItem styling; hidden unless the game is installed and has 2+ distinct options. https://claude.ai/code/session_01EUsZxzAWCjSh8LYRS9W3vT --- .../main/app/shell/StoreGameDetailScreen.kt | 153 ++++++++++++++---- app/src/main/app/shell/UnifiedActivity.kt | 61 +++++++ .../feature/stores/steam/data/LaunchInfo.kt | 2 + .../stores/steam/service/SteamService.kt | 56 +++++++ .../stores/steam/utils/KeyValueUtils.kt | 1 + .../feature/stores/steam/utils/SteamUtils.kt | 26 ++- app/src/main/res/values/strings.xml | 2 + .../display/XServerDisplayActivity.java | 29 +++- 8 files changed, 295 insertions(+), 35 deletions(-) diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt index 64b4fbd8e..45d7b3fcc 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -48,6 +48,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.automirrored.outlined.FactCheck import androidx.compose.material.icons.outlined.ArrowDropDown +import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.CloudSync import androidx.compose.material.icons.outlined.Construction import androidx.compose.material.icons.outlined.Delete @@ -57,6 +58,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 @@ -113,6 +115,13 @@ internal data class StoreDlcItem( val isInstalled: Boolean = false, ) +internal data class StoreLaunchOptionItem( + // Relative path, '/'-separated (appinfo config.launch entry). + val executable: String, + val arguments: String, + val label: String, +) + private val StoreBlack = Color.Black private val StoreCard = Color(0xFF12121B) private val StoreAccent = Color(0xFF1A9FFF) @@ -149,6 +158,9 @@ internal fun StoreGameDetailScreen( showWorkshop: Boolean = false, showVerifyFiles: Boolean = false, areSteamActionsEnabled: Boolean = true, + launchOptions: List = emptyList(), + selectedLaunchOption: StoreLaunchOptionItem? = null, + onSelectLaunchOption: (StoreLaunchOptionItem) -> Unit = {}, dlcs: List = emptyList(), selectedDlcIds: Set = emptySet(), isDlcSelectionEnabled: Boolean = true, @@ -186,7 +198,9 @@ internal fun StoreGameDetailScreen( val showUpdateCta = updateCheckAvailable && isUpdateAvailable val verifyFilesAvailable = showVerifyFiles && isInstalled val workshopAvailable = showWorkshop && isInstalled - val sourceMenuEnabled = updateCheckAvailable || verifyFilesAvailable || workshopAvailable + val launchOptionsAvailable = isInstalled && launchOptions.size >= 2 + val sourceMenuEnabled = + updateCheckAvailable || verifyFilesAvailable || workshopAvailable || launchOptionsAvailable val showDlcCard = dlcs.isNotEmpty() val showActionColumn = showDownloadCta || showUpdateCta || @@ -293,6 +307,9 @@ internal fun StoreGameDetailScreen( showCheckForUpdate = updateCheckAvailable, showVerifyFiles = verifyFilesAvailable, showWorkshop = workshopAvailable, + showLaunchOptions = launchOptionsAvailable, + launchOptions = launchOptions, + selectedLaunchOption = selectedLaunchOption, isCheckingForUpdate = isCheckingForUpdate, areSteamActionsEnabled = areSteamActionsEnabled, isUpdateCheckEnabled = @@ -303,6 +320,7 @@ internal fun StoreGameDetailScreen( onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, + onSelectLaunchOption = onSelectLaunchOption, ) } @@ -780,14 +798,19 @@ private fun StoreSourceTag( showCheckForUpdate: Boolean = false, showVerifyFiles: Boolean = false, showWorkshop: Boolean = false, + showLaunchOptions: Boolean = false, + launchOptions: List = emptyList(), + selectedLaunchOption: StoreLaunchOptionItem? = null, isCheckingForUpdate: Boolean = false, areSteamActionsEnabled: Boolean = true, isUpdateCheckEnabled: Boolean = true, onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, + onSelectLaunchOption: (StoreLaunchOptionItem) -> Unit = {}, ) { var menuOpen by remember { mutableStateOf(false) } + var launchOptionsPage by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableIntStateOf(0) } Box { Surface( @@ -797,7 +820,16 @@ private fun StoreSourceTag( modifier = Modifier .onSizeChanged { anchorHeightPx = it.height } - .then(if (menuEnabled) Modifier.clickable { menuOpen = true } else Modifier), + .then( + if (menuEnabled) { + Modifier.clickable { + launchOptionsPage = false + menuOpen = true + } + } else { + Modifier + }, + ), ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), @@ -832,34 +864,63 @@ private fun StoreSourceTag( val gapPx = with(LocalDensity.current) { 6.dp.roundToPx() } StoreSourceActionPopup( expanded = menuOpen, - onDismissRequest = { menuOpen = false }, + onDismissRequest = { + menuOpen = false + launchOptionsPage = false + }, offset = IntOffset(0, anchorHeightPx + gapPx), ) { - if (showVerifyFiles) { + if (launchOptionsPage) { StoreSourceMenuItem( - icon = Icons.AutoMirrored.Outlined.FactCheck, - label = stringResource(R.string.store_game_verify_files), - enabled = areSteamActionsEnabled && !isCheckingForUpdate, - ) { menuOpen = false; onVerifyFiles() } - } - if (showCheckForUpdate) { - StoreSourceMenuItem( - icon = Icons.Outlined.Refresh, - label = - if (isCheckingForUpdate) { - stringResource(R.string.store_game_checking_for_update) - } else { - stringResource(R.string.store_game_check_for_update) - }, - enabled = areSteamActionsEnabled && isUpdateCheckEnabled, - ) { menuOpen = false; onCheckForUpdate() } - } - if (showWorkshop) { - StoreSourceMenuItem( - icon = Icons.Outlined.Construction, - label = stringResource(R.string.store_game_workshop), - enabled = areSteamActionsEnabled, - ) { menuOpen = false; onWorkshop() } + icon = Icons.AutoMirrored.Outlined.ArrowBack, + label = stringResource(R.string.store_game_launch_options), + ) { launchOptionsPage = false } + StoreDlcDivider() + launchOptions.forEach { option -> + StoreLaunchOptionRow( + label = option.label, + selected = option == selectedLaunchOption, + enabled = areSteamActionsEnabled, + ) { + menuOpen = false + launchOptionsPage = false + onSelectLaunchOption(option) + } + } + } else { + if (showVerifyFiles) { + StoreSourceMenuItem( + icon = Icons.AutoMirrored.Outlined.FactCheck, + label = stringResource(R.string.store_game_verify_files), + enabled = areSteamActionsEnabled && !isCheckingForUpdate, + ) { menuOpen = false; onVerifyFiles() } + } + if (showCheckForUpdate) { + StoreSourceMenuItem( + icon = Icons.Outlined.Refresh, + label = + if (isCheckingForUpdate) { + stringResource(R.string.store_game_checking_for_update) + } else { + stringResource(R.string.store_game_check_for_update) + }, + enabled = areSteamActionsEnabled && isUpdateCheckEnabled, + ) { menuOpen = false; onCheckForUpdate() } + } + if (showWorkshop) { + StoreSourceMenuItem( + icon = Icons.Outlined.Construction, + label = stringResource(R.string.store_game_workshop), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onWorkshop() } + } + if (showLaunchOptions) { + StoreSourceMenuItem( + icon = Icons.Outlined.RocketLaunch, + label = stringResource(R.string.store_game_launch_options), + enabled = areSteamActionsEnabled, + ) { launchOptionsPage = true } + } } } } @@ -948,6 +1009,44 @@ private fun StoreSourceMenuItem( } } +@Composable +private fun StoreLaunchOptionRow( + label: String, + selected: Boolean, + enabled: Boolean = true, + onClick: () -> Unit, +) { + val contentColor = if (enabled) Color.White else Color.White.copy(alpha = 0.45f) + Row( + modifier = + Modifier + .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) + .padding(start = 14.dp, end = 14.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box(Modifier.size(16.dp), contentAlignment = Alignment.Center) { + if (selected) { + Icon( + Icons.Outlined.Check, + contentDescription = null, + tint = StoreAccent, + modifier = Modifier.size(16.dp), + ) + } + } + Text( + label, + color = if (selected) StoreAccent else contentColor, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 280.dp), + ) + } +} + @Composable private fun StoreStatChip( icon: ImageVector, diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 80a3e4e86..b868fae39 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -8801,6 +8801,8 @@ 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 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( @@ -8866,6 +8868,41 @@ class UnifiedActivity : } } + LaunchedEffect(app.id, installed) { + if (installed != true) { + launchOptions = emptyList() + selectedLaunchOption = null + return@LaunchedEffect + } + val (options, selected) = + withContext(Dispatchers.IO) { + val appDir = java.io.File(SteamService.getAppDirPath(app.id)) + val allOptions = + SteamService + .getWindowsLaunchInfos(app.id) + .map { info -> + StoreLaunchOptionItem( + executable = info.executable, + arguments = info.arguments, + label = info.description.ifBlank { info.executable.substringAfterLast('/') }, + ) + }.distinctBy { it.executable.lowercase() to it.arguments } + // 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(context, app.id) + 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 + } + launchOptions = options + selectedLaunchOption = selected + } + val totalDownloadSize = selectedManifestSizes.downloadSize val totalInstallSize = selectedManifestSizes.installSize val defaultPathSet = @@ -8986,6 +9023,30 @@ class UnifiedActivity : showWorkshop = isReallyInstalled, showVerifyFiles = isReallyInstalled, areSteamActionsEnabled = !hasBlockingSteamDownload, + launchOptions = launchOptions, + selectedLaunchOption = selectedLaunchOption, + onSelectLaunchOption = { option -> + scope.launch(Dispatchers.IO) { + val saved = + SteamService.setSelectedLaunchOption( + applicationContext, + app.id, + option.executable, + option.arguments, + ) + withContext(Dispatchers.Main) { + if (saved) { + selectedLaunchOption = option + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString(R.string.store_game_launch_option_failed), + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } + }, dlcs = dlcItems, selectedDlcIds = selectedDlcIds.toSet(), isDlcSelectionEnabled = steamDownloadRecord == null, 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 d2f6cefd3..c054ef01b 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -2306,6 +2306,62 @@ 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() + } + + /** + * 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. Call on an IO dispatcher. + */ + fun getSelectedLaunchOption( + context: Context, + appId: Int, + ): Pair { + val shortcut = findSteamShortcut(context, appId) + val exe = + shortcut?.getExtra("launch_exe_path").orEmpty().ifBlank { + shortcut?.container?.executablePath.orEmpty().ifBlank { getInstalledExe(appId) } + } + return exe.replace('\\', '/') to shortcut?.getExtra("launch_exe_args").orEmpty() + } + suspend fun deleteApp(appId: Int): Boolean = withContext(Dispatchers.IO) { val appDirPath = getAppDirPath(appId) diff --git a/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt b/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt index 64fe42af2..fef57ca0f 100644 --- a/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt +++ b/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt @@ -198,6 +198,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 c6b4636e5..2ec31c002 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 51b37c8b0..b241e0b6f 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -7493,8 +7493,10 @@ 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 = SteamUtils.combineSteamLaunchArgs( + shortcut.getExtra("launch_exe_args"), + shortcut.getSettingExtra("execArgs", container.getExecArgs())); + steamExtraArgs = !steamExtraArgs.isEmpty() ? " " + steamExtraArgs : ""; boolean useColdClient = parseBoolean(getShortcutSetting("useColdClient", container.isUseColdClient() ? "1" : "0")); boolean launchBionicSteam = isBionicSteamEnabledForShortcut(); @@ -7880,7 +7882,8 @@ 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 = SteamUtils.combineSteamLaunchArgs( + shortcut != null ? shortcut.getExtra("launch_exe_args") : "", perGameExecArgs); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); @@ -7956,7 +7959,8 @@ 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 = SteamUtils.combineSteamLaunchArgs( + shortcut != null ? shortcut.getExtra("launch_exe_args") : "", perGameExecArgs); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); @@ -9633,10 +9637,16 @@ 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. + String effectiveLaunchOptions = + SteamUtils.combineSteamLaunchArgs(selectedLaunchArgs, container.getExecArgs()); + String expectedStamp = "v2|" + appId + "|" + steamUserDataId + + "|" + effectiveLaunchOptions.hashCode(); String existingStamp = steamEnvStamp.exists() ? FileUtils.readString(steamEnvStamp).trim() : ""; boolean steamEnvWarm = expectedStamp.equals(existingStamp); @@ -9651,7 +9661,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 { @@ -9661,6 +9672,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"); From 9b09e9905decb5803d8874000a3c63d17804252d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 03:08:15 +0000 Subject: [PATCH 2/5] fix(steam): show Launch Options on the Library game screen too The v1 selector only existed on the store detail screen (StoreGameDetailScreen); tapping an installed game in the library grid opens LibraryGameDetailDialog -> LibraryGameLaunchScreen, which has its own duplicated STEAM dropdown - so the feature was invisible from the library. Mirror the submenu there (same two-page popup, using this screen's Launch* styling) and wire it in LibraryGameDetailDialog for Steam games only. Also fix two latent issues that could hide or weaken the selector: - Cached appinfo rows predate LaunchInfo.arguments, so options that differ only by args deduped down to one entry and fell under the 2-option visibility gate. Dedup now includes the option label, and both dialogs do a two-phase load: show cached options immediately, then SteamService.refreshAppInfoFromPics (new public wrapper around fetchLatestSteamAppInfo + persistLatestSteamAppInfo) re-fetches the app's PICS appinfo and reloads, healing upgraded installs on first open. - Extracted the option-list builder and selection persistence into shared UnifiedActivity helpers used by both screens, replacing GameManagerDialog's inline copies. https://claude.ai/code/session_01EUsZxzAWCjSh8LYRS9W3vT --- .../main/app/shell/LibraryGameLaunchScreen.kt | 137 ++++++++++++++--- app/src/main/app/shell/UnifiedActivity.kt | 145 ++++++++++++------ .../stores/steam/service/SteamService.kt | 17 ++ 3 files changed, 233 insertions(+), 66 deletions(-) diff --git a/app/src/main/app/shell/LibraryGameLaunchScreen.kt b/app/src/main/app/shell/LibraryGameLaunchScreen.kt index 9f1bac0b2..efba8a7ea 100644 --- a/app/src/main/app/shell/LibraryGameLaunchScreen.kt +++ b/app/src/main/app/shell/LibraryGameLaunchScreen.kt @@ -43,12 +43,14 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.automirrored.outlined.FactCheck import androidx.compose.material.icons.outlined.ArrowDropDown +import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.CloudSync import androidx.compose.material.icons.outlined.Construction 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 @@ -58,6 +60,7 @@ import androidx.compose.material.icons.outlined.SportsEsports import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.Warning import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -133,6 +136,9 @@ internal fun LibraryGameLaunchScreen( showVerifyFiles: Boolean = true, showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, + launchOptions: List = emptyList(), + selectedLaunchOption: StoreLaunchOptionItem? = null, + onSelectLaunchOption: (StoreLaunchOptionItem) -> Unit = {}, playEnabled: Boolean = true, playDisabledLabel: String? = null, onBack: () -> Unit, @@ -268,10 +274,14 @@ internal fun LibraryGameLaunchScreen( showVerifyFiles = showVerifyFiles, showCheckForUpdate = showCheckForUpdate, showWorkshop = showWorkshop, + showLaunchOptions = launchOptions.size >= 2, + launchOptions = launchOptions, + selectedLaunchOption = selectedLaunchOption, areSteamActionsEnabled = areSteamActionsEnabled, onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, + onSelectLaunchOption = onSelectLaunchOption, ) } @@ -803,12 +813,17 @@ private fun SourceTag( showVerifyFiles: Boolean = true, showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, + showLaunchOptions: Boolean = false, + launchOptions: List = emptyList(), + selectedLaunchOption: StoreLaunchOptionItem? = null, areSteamActionsEnabled: Boolean = true, onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, + onSelectLaunchOption: (StoreLaunchOptionItem) -> Unit = {}, ) { var menuOpen by remember { mutableStateOf(false) } + var launchOptionsPage by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableStateOf(0) } Box { Surface( @@ -818,7 +833,16 @@ private fun SourceTag( modifier = Modifier .onSizeChanged { anchorHeightPx = it.height } - .then(if (menuEnabled) Modifier.clickable { menuOpen = true } else Modifier), + .then( + if (menuEnabled) { + Modifier.clickable { + launchOptionsPage = false + menuOpen = true + } + } else { + Modifier + }, + ), ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), @@ -853,29 +877,62 @@ private fun SourceTag( val gapPx = with(LocalDensity.current) { 6.dp.roundToPx() } LaunchSourceActionPopup( expanded = menuOpen, - onDismissRequest = { menuOpen = false }, + onDismissRequest = { + menuOpen = false + launchOptionsPage = false + }, offset = IntOffset(0, anchorHeightPx + gapPx), ) { - if (showVerifyFiles) { - LaunchSourceMenuItem( - icon = Icons.AutoMirrored.Outlined.FactCheck, - label = stringResource(R.string.store_game_verify_files), - enabled = areSteamActionsEnabled, - ) { menuOpen = false; onVerifyFiles() } - } - if (showCheckForUpdate) { - LaunchSourceMenuItem( - icon = Icons.Outlined.Refresh, - label = stringResource(R.string.store_game_check_for_update), - enabled = areSteamActionsEnabled, - ) { menuOpen = false; onCheckForUpdate() } - } - if (showWorkshop) { + if (launchOptionsPage) { LaunchSourceMenuItem( - icon = Icons.Outlined.Construction, - label = stringResource(R.string.store_game_workshop), - enabled = areSteamActionsEnabled, - ) { menuOpen = false; onWorkshop() } + icon = Icons.AutoMirrored.Outlined.ArrowBack, + label = stringResource(R.string.store_game_launch_options), + ) { launchOptionsPage = false } + HorizontalDivider( + color = Color.White.copy(alpha = 0.16f), + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 12.dp), + ) + launchOptions.forEach { option -> + LaunchOptionRow( + label = option.label, + selected = option == selectedLaunchOption, + enabled = areSteamActionsEnabled, + ) { + menuOpen = false + launchOptionsPage = false + onSelectLaunchOption(option) + } + } + } else { + if (showVerifyFiles) { + LaunchSourceMenuItem( + icon = Icons.AutoMirrored.Outlined.FactCheck, + label = stringResource(R.string.store_game_verify_files), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onVerifyFiles() } + } + if (showCheckForUpdate) { + LaunchSourceMenuItem( + icon = Icons.Outlined.Refresh, + label = stringResource(R.string.store_game_check_for_update), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onCheckForUpdate() } + } + if (showWorkshop) { + LaunchSourceMenuItem( + icon = Icons.Outlined.Construction, + label = stringResource(R.string.store_game_workshop), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onWorkshop() } + } + if (showLaunchOptions) { + LaunchSourceMenuItem( + icon = Icons.Outlined.RocketLaunch, + label = stringResource(R.string.store_game_launch_options), + enabled = areSteamActionsEnabled, + ) { launchOptionsPage = true } + } } } } @@ -964,6 +1021,44 @@ private fun LaunchSourceMenuItem( } } +@Composable +private fun LaunchOptionRow( + label: String, + selected: Boolean, + enabled: Boolean = true, + onClick: () -> Unit, +) { + val contentColor = if (enabled) Color.White else Color.White.copy(alpha = 0.45f) + Row( + modifier = + Modifier + .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) + .padding(start = 14.dp, end = 14.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box(Modifier.size(16.dp), contentAlignment = Alignment.Center) { + if (selected) { + Icon( + Icons.Outlined.Check, + contentDescription = null, + tint = LaunchAccent, + modifier = Modifier.size(16.dp), + ) + } + } + Text( + label, + color = if (selected) LaunchAccent else contentColor, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 280.dp), + ) + } +} + @Composable private fun GameStatChip( icon: ImageVector, diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index b868fae39..2a22fafb1 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -217,6 +217,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 @@ -4585,6 +4586,28 @@ 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 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 + } + val (options, selected) = loadSteamLaunchOptions(app.id) + launchOptions = options + selectedLaunchOption = selected + // Heal cached appinfo rows that predate LaunchInfo.arguments. + if (SteamService.refreshAppInfoFromPics(app.id)) { + val (freshOptions, freshSelected) = loadSteamLaunchOptions(app.id) + launchOptions = freshOptions + selectedLaunchOption = freshSelected + } + } val libraryDownloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState( initial = com.winlator.cmod.app.service.download.DownloadCoordinator.snapshotRecords(), @@ -5313,6 +5336,13 @@ class UnifiedActivity : (!isEpic || epicGame?.isInstalled == true) && (!isGog || gogGame?.isInstalled == true), showWorkshop = !isEpic && !isGog, + launchOptions = launchOptions, + selectedLaunchOption = selectedLaunchOption, + onSelectLaunchOption = { option -> + persistSteamLaunchOptionSelection(app.id, option, scope) { + selectedLaunchOption = it + } + }, areSteamActionsEnabled = when { isEpic -> !hasBlockingEpicDownloadForLibrary @@ -8782,6 +8812,68 @@ class UnifiedActivity : } } + /** + * 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 + } + + 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( @@ -8874,33 +8966,15 @@ class UnifiedActivity : selectedLaunchOption = null return@LaunchedEffect } - val (options, selected) = - withContext(Dispatchers.IO) { - val appDir = java.io.File(SteamService.getAppDirPath(app.id)) - val allOptions = - SteamService - .getWindowsLaunchInfos(app.id) - .map { info -> - StoreLaunchOptionItem( - executable = info.executable, - arguments = info.arguments, - label = info.description.ifBlank { info.executable.substringAfterLast('/') }, - ) - }.distinctBy { it.executable.lowercase() to it.arguments } - // 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(context, app.id) - 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 - } + val (options, selected) = loadSteamLaunchOptions(app.id) launchOptions = options selectedLaunchOption = selected + // Heal cached appinfo rows that predate LaunchInfo.arguments. + if (SteamService.refreshAppInfoFromPics(app.id)) { + val (freshOptions, freshSelected) = loadSteamLaunchOptions(app.id) + launchOptions = freshOptions + selectedLaunchOption = freshSelected + } } val totalDownloadSize = selectedManifestSizes.downloadSize @@ -9026,26 +9100,7 @@ class UnifiedActivity : launchOptions = launchOptions, selectedLaunchOption = selectedLaunchOption, onSelectLaunchOption = { option -> - scope.launch(Dispatchers.IO) { - val saved = - SteamService.setSelectedLaunchOption( - applicationContext, - app.id, - option.executable, - option.arguments, - ) - withContext(Dispatchers.Main) { - if (saved) { - selectedLaunchOption = option - } else { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString(R.string.store_game_launch_option_failed), - android.widget.Toast.LENGTH_SHORT, - ) - } - } - } + persistSteamLaunchOptionSelection(app.id, option, scope) { selectedLaunchOption = it } }, dlcs = dlcItems, selectedDlcIds = selectedDlcIds.toSet(), diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index c054ef01b..7f2501364 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -7482,6 +7482,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, From 76231ef92d4bd127918527ad5bb7ca170598e1f1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 03:17:22 +0000 Subject: [PATCH 3/5] ui(steam): Workshop-style window for the Launch Options picker The in-popup submenu rendered as an awkward full-width overlay. Replace it with a centered modal window that follows the Workshop window's design language: dimmed scrim, 560dp-max rounded Surface, header with an accent icon badge + LAUNCH OPTIONS overline + game title + count chip + close button, and a divided row list. Rows show the option label with its executable/arguments as the secondary line; tapping persists the selection and moves the check mark. - New LaunchOptionsScreen.kt mirroring WorkshopScreen's palette and layout (all vector icons; no new drawable assets needed). - Both STEAM dropdowns return to a flat menu whose "Launch Options" item opens the window (showLaunchOptions/onLaunchOptions params replace the list plumbing through the SourceTags). - LaunchOptionsDialog host added next to WorkshopDialog in both GameManagerDialog and LibraryGameDetailDialog. https://claude.ai/code/session_01EUsZxzAWCjSh8LYRS9W3vT --- app/src/main/app/shell/LaunchOptionsScreen.kt | 243 ++++++++++++++++++ .../main/app/shell/LibraryGameLaunchScreen.kt | 145 +++-------- .../main/app/shell/StoreGameDetailScreen.kt | 150 +++-------- app/src/main/app/shell/UnifiedActivity.kt | 71 ++++- 4 files changed, 371 insertions(+), 238 deletions(-) create mode 100644 app/src/main/app/shell/LaunchOptionsScreen.kt diff --git a/app/src/main/app/shell/LaunchOptionsScreen.kt b/app/src/main/app/shell/LaunchOptionsScreen.kt new file mode 100644 index 000000000..8e89cdbb2 --- /dev/null +++ b/app/src/main/app/shell/LaunchOptionsScreen.kt @@ -0,0 +1,243 @@ +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 + +// 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 efba8a7ea..11f0c40b2 100644 --- a/app/src/main/app/shell/LibraryGameLaunchScreen.kt +++ b/app/src/main/app/shell/LibraryGameLaunchScreen.kt @@ -43,7 +43,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.automirrored.outlined.FactCheck import androidx.compose.material.icons.outlined.ArrowDropDown -import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.CloudSync import androidx.compose.material.icons.outlined.Construction import androidx.compose.material.icons.outlined.Delete @@ -60,7 +59,6 @@ import androidx.compose.material.icons.outlined.SportsEsports import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.Warning import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -136,9 +134,8 @@ internal fun LibraryGameLaunchScreen( showVerifyFiles: Boolean = true, showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, - launchOptions: List = emptyList(), - selectedLaunchOption: StoreLaunchOptionItem? = null, - onSelectLaunchOption: (StoreLaunchOptionItem) -> Unit = {}, + showLaunchOptions: Boolean = false, + onLaunchOptions: () -> Unit = {}, playEnabled: Boolean = true, playDisabledLabel: String? = null, onBack: () -> Unit, @@ -274,14 +271,12 @@ internal fun LibraryGameLaunchScreen( showVerifyFiles = showVerifyFiles, showCheckForUpdate = showCheckForUpdate, showWorkshop = showWorkshop, - showLaunchOptions = launchOptions.size >= 2, - launchOptions = launchOptions, - selectedLaunchOption = selectedLaunchOption, + showLaunchOptions = showLaunchOptions, areSteamActionsEnabled = areSteamActionsEnabled, onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, - onSelectLaunchOption = onSelectLaunchOption, + onLaunchOptions = onLaunchOptions, ) } @@ -814,16 +809,13 @@ private fun SourceTag( showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, showLaunchOptions: Boolean = false, - launchOptions: List = emptyList(), - selectedLaunchOption: StoreLaunchOptionItem? = null, areSteamActionsEnabled: Boolean = true, onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, - onSelectLaunchOption: (StoreLaunchOptionItem) -> Unit = {}, + onLaunchOptions: () -> Unit = {}, ) { var menuOpen by remember { mutableStateOf(false) } - var launchOptionsPage by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableStateOf(0) } Box { Surface( @@ -833,16 +825,7 @@ private fun SourceTag( modifier = Modifier .onSizeChanged { anchorHeightPx = it.height } - .then( - if (menuEnabled) { - Modifier.clickable { - launchOptionsPage = false - menuOpen = true - } - } else { - Modifier - }, - ), + .then(if (menuEnabled) Modifier.clickable { menuOpen = true } else Modifier), ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), @@ -877,62 +860,36 @@ private fun SourceTag( val gapPx = with(LocalDensity.current) { 6.dp.roundToPx() } LaunchSourceActionPopup( expanded = menuOpen, - onDismissRequest = { - menuOpen = false - launchOptionsPage = false - }, + onDismissRequest = { menuOpen = false }, offset = IntOffset(0, anchorHeightPx + gapPx), ) { - if (launchOptionsPage) { + if (showVerifyFiles) { LaunchSourceMenuItem( - icon = Icons.AutoMirrored.Outlined.ArrowBack, + icon = Icons.AutoMirrored.Outlined.FactCheck, + label = stringResource(R.string.store_game_verify_files), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onVerifyFiles() } + } + if (showCheckForUpdate) { + LaunchSourceMenuItem( + icon = Icons.Outlined.Refresh, + label = stringResource(R.string.store_game_check_for_update), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onCheckForUpdate() } + } + if (showWorkshop) { + LaunchSourceMenuItem( + icon = Icons.Outlined.Construction, + label = stringResource(R.string.store_game_workshop), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onWorkshop() } + } + if (showLaunchOptions) { + LaunchSourceMenuItem( + icon = Icons.Outlined.RocketLaunch, label = stringResource(R.string.store_game_launch_options), - ) { launchOptionsPage = false } - HorizontalDivider( - color = Color.White.copy(alpha = 0.16f), - thickness = 1.dp, - modifier = Modifier.padding(horizontal = 12.dp), - ) - launchOptions.forEach { option -> - LaunchOptionRow( - label = option.label, - selected = option == selectedLaunchOption, - enabled = areSteamActionsEnabled, - ) { - menuOpen = false - launchOptionsPage = false - onSelectLaunchOption(option) - } - } - } else { - if (showVerifyFiles) { - LaunchSourceMenuItem( - icon = Icons.AutoMirrored.Outlined.FactCheck, - label = stringResource(R.string.store_game_verify_files), - enabled = areSteamActionsEnabled, - ) { menuOpen = false; onVerifyFiles() } - } - if (showCheckForUpdate) { - LaunchSourceMenuItem( - icon = Icons.Outlined.Refresh, - label = stringResource(R.string.store_game_check_for_update), - enabled = areSteamActionsEnabled, - ) { menuOpen = false; onCheckForUpdate() } - } - if (showWorkshop) { - LaunchSourceMenuItem( - icon = Icons.Outlined.Construction, - label = stringResource(R.string.store_game_workshop), - enabled = areSteamActionsEnabled, - ) { menuOpen = false; onWorkshop() } - } - if (showLaunchOptions) { - LaunchSourceMenuItem( - icon = Icons.Outlined.RocketLaunch, - label = stringResource(R.string.store_game_launch_options), - enabled = areSteamActionsEnabled, - ) { launchOptionsPage = true } - } + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onLaunchOptions() } } } } @@ -1021,44 +978,6 @@ private fun LaunchSourceMenuItem( } } -@Composable -private fun LaunchOptionRow( - label: String, - selected: Boolean, - enabled: Boolean = true, - onClick: () -> Unit, -) { - val contentColor = if (enabled) Color.White else Color.White.copy(alpha = 0.45f) - Row( - modifier = - Modifier - .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) - .padding(start = 14.dp, end = 14.dp, top = 10.dp, bottom = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Box(Modifier.size(16.dp), contentAlignment = Alignment.Center) { - if (selected) { - Icon( - Icons.Outlined.Check, - contentDescription = null, - tint = LaunchAccent, - modifier = Modifier.size(16.dp), - ) - } - } - Text( - label, - color = if (selected) LaunchAccent else contentColor, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.widthIn(max = 280.dp), - ) - } -} - @Composable private fun GameStatChip( icon: ImageVector, diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt index 45d7b3fcc..3451bebab 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -48,7 +48,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.automirrored.outlined.FactCheck import androidx.compose.material.icons.outlined.ArrowDropDown -import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.CloudSync import androidx.compose.material.icons.outlined.Construction import androidx.compose.material.icons.outlined.Delete @@ -158,9 +157,8 @@ internal fun StoreGameDetailScreen( showWorkshop: Boolean = false, showVerifyFiles: Boolean = false, areSteamActionsEnabled: Boolean = true, - launchOptions: List = emptyList(), - selectedLaunchOption: StoreLaunchOptionItem? = null, - onSelectLaunchOption: (StoreLaunchOptionItem) -> Unit = {}, + showLaunchOptions: Boolean = false, + onLaunchOptions: () -> Unit = {}, dlcs: List = emptyList(), selectedDlcIds: Set = emptySet(), isDlcSelectionEnabled: Boolean = true, @@ -198,7 +196,7 @@ internal fun StoreGameDetailScreen( val showUpdateCta = updateCheckAvailable && isUpdateAvailable val verifyFilesAvailable = showVerifyFiles && isInstalled val workshopAvailable = showWorkshop && isInstalled - val launchOptionsAvailable = isInstalled && launchOptions.size >= 2 + val launchOptionsAvailable = showLaunchOptions && isInstalled val sourceMenuEnabled = updateCheckAvailable || verifyFilesAvailable || workshopAvailable || launchOptionsAvailable val showDlcCard = dlcs.isNotEmpty() @@ -308,8 +306,6 @@ internal fun StoreGameDetailScreen( showVerifyFiles = verifyFilesAvailable, showWorkshop = workshopAvailable, showLaunchOptions = launchOptionsAvailable, - launchOptions = launchOptions, - selectedLaunchOption = selectedLaunchOption, isCheckingForUpdate = isCheckingForUpdate, areSteamActionsEnabled = areSteamActionsEnabled, isUpdateCheckEnabled = @@ -320,7 +316,7 @@ internal fun StoreGameDetailScreen( onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, - onSelectLaunchOption = onSelectLaunchOption, + onLaunchOptions = onLaunchOptions, ) } @@ -799,18 +795,15 @@ private fun StoreSourceTag( showVerifyFiles: Boolean = false, showWorkshop: Boolean = false, showLaunchOptions: Boolean = false, - launchOptions: List = emptyList(), - selectedLaunchOption: StoreLaunchOptionItem? = null, isCheckingForUpdate: Boolean = false, areSteamActionsEnabled: Boolean = true, isUpdateCheckEnabled: Boolean = true, onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, - onSelectLaunchOption: (StoreLaunchOptionItem) -> Unit = {}, + onLaunchOptions: () -> Unit = {}, ) { var menuOpen by remember { mutableStateOf(false) } - var launchOptionsPage by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableIntStateOf(0) } Box { Surface( @@ -820,16 +813,7 @@ private fun StoreSourceTag( modifier = Modifier .onSizeChanged { anchorHeightPx = it.height } - .then( - if (menuEnabled) { - Modifier.clickable { - launchOptionsPage = false - menuOpen = true - } - } else { - Modifier - }, - ), + .then(if (menuEnabled) Modifier.clickable { menuOpen = true } else Modifier), ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), @@ -864,63 +848,41 @@ private fun StoreSourceTag( val gapPx = with(LocalDensity.current) { 6.dp.roundToPx() } StoreSourceActionPopup( expanded = menuOpen, - onDismissRequest = { - menuOpen = false - launchOptionsPage = false - }, + onDismissRequest = { menuOpen = false }, offset = IntOffset(0, anchorHeightPx + gapPx), ) { - if (launchOptionsPage) { + if (showVerifyFiles) { + StoreSourceMenuItem( + icon = Icons.AutoMirrored.Outlined.FactCheck, + label = stringResource(R.string.store_game_verify_files), + enabled = areSteamActionsEnabled && !isCheckingForUpdate, + ) { menuOpen = false; onVerifyFiles() } + } + if (showCheckForUpdate) { + StoreSourceMenuItem( + icon = Icons.Outlined.Refresh, + label = + if (isCheckingForUpdate) { + stringResource(R.string.store_game_checking_for_update) + } else { + stringResource(R.string.store_game_check_for_update) + }, + enabled = areSteamActionsEnabled && isUpdateCheckEnabled, + ) { menuOpen = false; onCheckForUpdate() } + } + if (showWorkshop) { StoreSourceMenuItem( - icon = Icons.AutoMirrored.Outlined.ArrowBack, + icon = Icons.Outlined.Construction, + label = stringResource(R.string.store_game_workshop), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onWorkshop() } + } + if (showLaunchOptions) { + StoreSourceMenuItem( + icon = Icons.Outlined.RocketLaunch, label = stringResource(R.string.store_game_launch_options), - ) { launchOptionsPage = false } - StoreDlcDivider() - launchOptions.forEach { option -> - StoreLaunchOptionRow( - label = option.label, - selected = option == selectedLaunchOption, - enabled = areSteamActionsEnabled, - ) { - menuOpen = false - launchOptionsPage = false - onSelectLaunchOption(option) - } - } - } else { - if (showVerifyFiles) { - StoreSourceMenuItem( - icon = Icons.AutoMirrored.Outlined.FactCheck, - label = stringResource(R.string.store_game_verify_files), - enabled = areSteamActionsEnabled && !isCheckingForUpdate, - ) { menuOpen = false; onVerifyFiles() } - } - if (showCheckForUpdate) { - StoreSourceMenuItem( - icon = Icons.Outlined.Refresh, - label = - if (isCheckingForUpdate) { - stringResource(R.string.store_game_checking_for_update) - } else { - stringResource(R.string.store_game_check_for_update) - }, - enabled = areSteamActionsEnabled && isUpdateCheckEnabled, - ) { menuOpen = false; onCheckForUpdate() } - } - if (showWorkshop) { - StoreSourceMenuItem( - icon = Icons.Outlined.Construction, - label = stringResource(R.string.store_game_workshop), - enabled = areSteamActionsEnabled, - ) { menuOpen = false; onWorkshop() } - } - if (showLaunchOptions) { - StoreSourceMenuItem( - icon = Icons.Outlined.RocketLaunch, - label = stringResource(R.string.store_game_launch_options), - enabled = areSteamActionsEnabled, - ) { launchOptionsPage = true } - } + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onLaunchOptions() } } } } @@ -1009,44 +971,6 @@ private fun StoreSourceMenuItem( } } -@Composable -private fun StoreLaunchOptionRow( - label: String, - selected: Boolean, - enabled: Boolean = true, - onClick: () -> Unit, -) { - val contentColor = if (enabled) Color.White else Color.White.copy(alpha = 0.45f) - Row( - modifier = - Modifier - .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) - .padding(start = 14.dp, end = 14.dp, top = 10.dp, bottom = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Box(Modifier.size(16.dp), contentAlignment = Alignment.Center) { - if (selected) { - Icon( - Icons.Outlined.Check, - contentDescription = null, - tint = StoreAccent, - modifier = Modifier.size(16.dp), - ) - } - } - Text( - label, - color = if (selected) StoreAccent else contentColor, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.widthIn(max = 280.dp), - ) - } -} - @Composable private fun StoreStatChip( icon: ImageVector, diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 2a22fafb1..1e5adfb57 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -4588,6 +4588,7 @@ class UnifiedActivity : 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) { @@ -5336,13 +5337,8 @@ class UnifiedActivity : (!isEpic || epicGame?.isInstalled == true) && (!isGog || gogGame?.isInstalled == true), showWorkshop = !isEpic && !isGog, - launchOptions = launchOptions, - selectedLaunchOption = selectedLaunchOption, - onSelectLaunchOption = { option -> - persistSteamLaunchOptionSelection(app.id, option, scope) { - selectedLaunchOption = it - } - }, + showLaunchOptions = launchOptions.size >= 2, + onLaunchOptions = { showLaunchOptionsDialog = true }, areSteamActionsEnabled = when { isEpic -> !hasBlockingEpicDownloadForLibrary @@ -5823,6 +5819,20 @@ class UnifiedActivity : onDismissRequest = { showWorkshopDialog = false }, ) } + + if (showLaunchOptionsDialog) { + LaunchOptionsDialog( + gameTitle = app.name, + options = launchOptions, + selectedOption = selectedLaunchOption, + onSelect = { option -> + persistSteamLaunchOptionSelection(app.id, option, scope) { + selectedLaunchOption = it + } + }, + onDismissRequest = { showLaunchOptionsDialog = false }, + ) + } } } } @@ -8893,6 +8903,7 @@ 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) } @@ -9097,11 +9108,8 @@ class UnifiedActivity : showWorkshop = isReallyInstalled, showVerifyFiles = isReallyInstalled, areSteamActionsEnabled = !hasBlockingSteamDownload, - launchOptions = launchOptions, - selectedLaunchOption = selectedLaunchOption, - onSelectLaunchOption = { option -> - persistSteamLaunchOptionSelection(app.id, option, scope) { selectedLaunchOption = it } - }, + showLaunchOptions = launchOptions.size >= 2, + onLaunchOptions = { showLaunchOptionsDialog = true }, dlcs = dlcItems, selectedDlcIds = selectedDlcIds.toSet(), isDlcSelectionEnabled = steamDownloadRecord == null, @@ -9240,6 +9248,45 @@ class UnifiedActivity : onDismissRequest = { showWorkshopDialog = false }, ) } + + if (showLaunchOptionsDialog) { + LaunchOptionsDialog( + gameTitle = app.name, + options = launchOptions, + selectedOption = selectedLaunchOption, + onSelect = { option -> + persistSteamLaunchOptionSelection(app.id, option, scope) { selectedLaunchOption = it } + }, + onDismissRequest = { showLaunchOptionsDialog = false }, + ) + } + } + + /** Hosts the Workshop-styled launch-option picker window over a game detail dialog. */ + @Composable + private fun LaunchOptionsDialog( + gameTitle: String, + options: List, + selectedOption: StoreLaunchOptionItem?, + onSelect: (StoreLaunchOptionItem) -> Unit, + onDismissRequest: () -> Unit, + ) { + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + StoreLaunchOptionsScreen( + gameTitle = gameTitle, + options = options, + selectedOption = selectedOption, + onSelect = onSelect, + onClose = onDismissRequest, + ) + } } @Composable From fe2cb9ba7c4ead9d660d718cdf62ecbb36961814 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 10:54:46 +0000 Subject: [PATCH 4/5] review(steam): launch options cleanup pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from a full-branch review applied: - PICS appinfo re-fetch now runs once per app per process (retried on failure) instead of on every game-detail open, and the two-phase load + refresh logic is shared by both dialogs via loadSteamLaunchOptionsRefreshing — drops a network round-trip and a duplicate shortcut scan from repeat opens. - LaunchOptionsDialog owns selection persistence (appId + onSelectionSaved), removing the duplicated persist lambda from both host sites. - XServerDisplayActivity: single combineWithSelectedLaunchArgs resolver replaces the getExtra+combine pattern repeated across the direct-launch and both ColdClient INI sites. - Steam-env stamp stores the raw effective LaunchOptions line instead of its hashCode — exact equality, no silent collision skips. - StoreLaunchOptionItem moved to LaunchOptionsScreen.kt where the picker lives. https://claude.ai/code/session_01EUsZxzAWCjSh8LYRS9W3vT --- app/src/main/app/shell/LaunchOptionsScreen.kt | 8 ++ .../main/app/shell/StoreGameDetailScreen.kt | 7 -- app/src/main/app/shell/UnifiedActivity.kt | 75 ++++++++++++------- .../display/XServerDisplayActivity.java | 24 ++++-- 4 files changed, 72 insertions(+), 42 deletions(-) diff --git a/app/src/main/app/shell/LaunchOptionsScreen.kt b/app/src/main/app/shell/LaunchOptionsScreen.kt index 8e89cdbb2..8309a381b 100644 --- a/app/src/main/app/shell/LaunchOptionsScreen.kt +++ b/app/src/main/app/shell/LaunchOptionsScreen.kt @@ -45,6 +45,14 @@ 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) diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt index 3451bebab..f936b84a4 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -114,13 +114,6 @@ internal data class StoreDlcItem( val isInstalled: Boolean = false, ) -internal data class StoreLaunchOptionItem( - // Relative path, '/'-separated (appinfo config.launch entry). - val executable: String, - val arguments: String, - val label: String, -) - private val StoreBlack = Color.Black private val StoreCard = Color(0xFF12121B) private val StoreAccent = Color(0xFF1A9FFF) diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 1e5adfb57..6847a6622 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -4599,14 +4599,9 @@ class UnifiedActivity : selectedLaunchOption = null return@LaunchedEffect } - val (options, selected) = loadSteamLaunchOptions(app.id) - launchOptions = options - selectedLaunchOption = selected - // Heal cached appinfo rows that predate LaunchInfo.arguments. - if (SteamService.refreshAppInfoFromPics(app.id)) { - val (freshOptions, freshSelected) = loadSteamLaunchOptions(app.id) - launchOptions = freshOptions - selectedLaunchOption = freshSelected + loadSteamLaunchOptionsRefreshing(app.id) { options, selected -> + launchOptions = options + selectedLaunchOption = selected } } @@ -5822,14 +5817,11 @@ class UnifiedActivity : if (showLaunchOptionsDialog) { LaunchOptionsDialog( + appId = app.id, gameTitle = app.name, options = launchOptions, selectedOption = selectedLaunchOption, - onSelect = { option -> - persistSteamLaunchOptionSelection(app.id, option, scope) { - selectedLaunchOption = it - } - }, + onSelectionSaved = { selectedLaunchOption = it }, onDismissRequest = { showLaunchOptionsDialog = false }, ) } @@ -8822,6 +8814,11 @@ 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. @@ -8856,6 +8853,28 @@ class UnifiedActivity : 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, @@ -8977,14 +8996,9 @@ class UnifiedActivity : selectedLaunchOption = null return@LaunchedEffect } - val (options, selected) = loadSteamLaunchOptions(app.id) - launchOptions = options - selectedLaunchOption = selected - // Heal cached appinfo rows that predate LaunchInfo.arguments. - if (SteamService.refreshAppInfoFromPics(app.id)) { - val (freshOptions, freshSelected) = loadSteamLaunchOptions(app.id) - launchOptions = freshOptions - selectedLaunchOption = freshSelected + loadSteamLaunchOptionsRefreshing(app.id) { options, selected -> + launchOptions = options + selectedLaunchOption = selected } } @@ -9251,26 +9265,31 @@ class UnifiedActivity : if (showLaunchOptionsDialog) { LaunchOptionsDialog( + appId = app.id, gameTitle = app.name, options = launchOptions, selectedOption = selectedLaunchOption, - onSelect = { option -> - persistSteamLaunchOptionSelection(app.id, option, scope) { selectedLaunchOption = it } - }, + onSelectionSaved = { selectedLaunchOption = it }, onDismissRequest = { showLaunchOptionsDialog = false }, ) } } - /** Hosts the Workshop-styled launch-option picker window over a game detail dialog. */ + /** + * 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?, - onSelect: (StoreLaunchOptionItem) -> Unit, + onSelectionSaved: (StoreLaunchOptionItem) -> Unit, onDismissRequest: () -> Unit, ) { + val scope = rememberCoroutineScope() Dialog( onDismissRequest = onDismissRequest, properties = @@ -9283,7 +9302,9 @@ class UnifiedActivity : gameTitle = gameTitle, options = options, selectedOption = selectedOption, - onSelect = onSelect, + onSelect = { option -> + persistSteamLaunchOptionSelection(appId, option, scope, onSelectionSaved) + }, onClose = onDismissRequest, ) } diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index b241e0b6f..60c7dbb42 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -7493,9 +7493,8 @@ 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 = SteamUtils.combineSteamLaunchArgs( - shortcut.getExtra("launch_exe_args"), - shortcut.getSettingExtra("execArgs", container.getExecArgs())); + String steamExtraArgs = + combineWithSelectedLaunchArgs(shortcut.getSettingExtra("execArgs", container.getExecArgs())); steamExtraArgs = !steamExtraArgs.isEmpty() ? " " + steamExtraArgs : ""; boolean useColdClient = parseBoolean(getShortcutSetting("useColdClient", container.isUseColdClient() ? "1" : "0")); @@ -7882,8 +7881,7 @@ private void writeColdClientIniDirect(int appId, String gameDirName, String rela } String perGameExecArgs = shortcut != null ? shortcut.getSettingExtra("execArgs", container.getExecArgs()) : container.getExecArgs(); - String exeCommandLine = SteamUtils.combineSteamLaunchArgs( - shortcut != null ? shortcut.getExtra("launch_exe_args") : "", perGameExecArgs); + String exeCommandLine = combineWithSelectedLaunchArgs(perGameExecArgs); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); @@ -7959,8 +7957,7 @@ private void writeColdClientIniForLaunch(int appId, String gameInstallPath, Stri } String perGameExecArgs = shortcut != null ? shortcut.getSettingExtra("execArgs", container.getExecArgs()) : container.getExecArgs(); - String exeCommandLine = SteamUtils.combineSteamLaunchArgs( - shortcut != null ? shortcut.getExtra("launch_exe_args") : "", perGameExecArgs); + String exeCommandLine = combineWithSelectedLaunchArgs(perGameExecArgs); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); @@ -8085,6 +8082,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); @@ -9643,10 +9650,11 @@ private void setupSteamEnvironment(int appId, File gameDir) { 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.hashCode(); + + "|" + effectiveLaunchOptions; String existingStamp = steamEnvStamp.exists() ? FileUtils.readString(steamEnvStamp).trim() : ""; boolean steamEnvWarm = expectedStamp.equals(existingStamp); From 7dcf506307070ca766ad7d3846b5f37034ceb6fe Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 21:22:53 +0000 Subject: [PATCH 5/5] perf(steam): read launch-option selection without constructing Shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getSelectedLaunchOption ran ContainerManager().loadShortcuts() on every game-detail open. Each Shortcut constructed that way decodes the full cover-art bitmap, runs PE icon extraction over the game exe and can rewrite .desktop files — a large transient heap spike and a write race against the UI's own shortcut reads, just to fetch two strings. New readSteamShortcutExtras parses the [Extra Data] section straight from the .desktop files and returns the owning container dir; the container.executablePath fallback is read from the .container config JSON directly. The heavyweight findSteamShortcut path remains only in setSelectedLaunchOption, which must materialize the shortcut to write. https://claude.ai/code/session_01EJFSZYUPkR1Cwer6CC2cNa --- .../stores/steam/service/SteamService.kt | 71 +++++++++++++++++-- 1 file changed, 66 insertions(+), 5 deletions(-) diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 7f2501364..1e4c200ef 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,60 @@ class SteamService : Service() { 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: @@ -2348,18 +2403,24 @@ class SteamService : Service() { /** * Currently effective launch option as (executable, arguments), resolved in the - * same order the launch path uses. Call on an IO dispatcher. + * 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 shortcut = findSteamShortcut(context, appId) + val found = runCatching { readSteamShortcutExtras(context, appId) }.getOrNull() + val extras = found?.first.orEmpty() val exe = - shortcut?.getExtra("launch_exe_path").orEmpty().ifBlank { - shortcut?.container?.executablePath.orEmpty().ifBlank { getInstalledExe(appId) } + extras["launch_exe_path"].orEmpty().ifBlank { + found?.second?.let { readContainerExecutablePath(it) }.orEmpty().ifBlank { + getInstalledExe(appId) + } } - return exe.replace('\\', '/') to shortcut?.getExtra("launch_exe_args").orEmpty() + return exe.replace('\\', '/') to extras["launch_exe_args"].orEmpty() } suspend fun deleteApp(appId: Int): Boolean =