diff --git a/app/src/main/app/shell/LaunchOptionsScreen.kt b/app/src/main/app/shell/LaunchOptionsScreen.kt new file mode 100644 index 000000000..318eccb4d --- /dev/null +++ b/app/src/main/app/shell/LaunchOptionsScreen.kt @@ -0,0 +1,427 @@ +package com.winlator.cmod.app.shell + +import android.content.Context +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.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +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.platform.LocalContext +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 androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import com.winlator.cmod.R +import com.winlator.cmod.feature.stores.steam.service.SteamService +import com.winlator.cmod.shared.ui.nav.DialogPaneNav +import com.winlator.cmod.shared.ui.nav.LocalPaneNav +import com.winlator.cmod.shared.ui.nav.PaneNavRegistry +import com.winlator.cmod.shared.ui.nav.paneNavItem +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +/** 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, +) + +// Workshop window palette (WsBg scheme). +private val WsBg = Color(0xFF12121B) +private val WsBorder = Color(0xFF2A2A3A) +private val WsAccent = Color(0xFF1A9FFF) +private val WsAccentGlow = Color(0xFF58A6FF) +private val WsTextPrimary = Color(0xFFF0F4FF) +private val WsTextSecondary = Color(0xFF93A6BC) +private val WsScrim = Color(0xFF000000) + +/** Workshop-styled modal listing the game's `config.launch` entries; selecting a row persists it. */ +@Composable +internal fun StoreLaunchOptionsScreen( + gameTitle: String, + options: List, + selectedOption: StoreLaunchOptionItem?, + onSelect: (StoreLaunchOptionItem) -> Unit, + onClose: () -> Unit, +) { + val registry = remember { PaneNavRegistry() } + CompositionLocalProvider(LocalPaneNav provides registry) { + DialogPaneNav(registry, onDismiss = onClose) + BoxWithConstraints( + modifier = + Modifier + .fillMaxSize() + // Dim the game-detail screen behind so the modal reads as foreground. + .background(WsScrim.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 = WsBg, + border = BorderStroke(1.dp, WsBorder), + tonalElevation = 8.dp, + ) { + Column(Modifier.fillMaxWidth()) { + LaunchOptionsHeader( + gameTitle = gameTitle, + optionCount = options.size, + onClose = onClose, + ) + HorizontalDivider(color = WsBorder, thickness = 0.5.dp) + LazyColumn( + // fill = false: wrap short lists instead of stretching to 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(WsAccent.copy(alpha = 0.16f)), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Outlined.RocketLaunch, + contentDescription = null, + tint = WsAccentGlow, + modifier = Modifier.size(19.dp), + ) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + stringResource(R.string.store_game_launch_options).uppercase(), + color = WsTextSecondary, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.9.sp, + ) + Text( + gameTitle, + style = MaterialTheme.typography.titleSmall, + color = WsTextPrimary, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + val optionCountDescription = stringResource(R.string.store_game_launch_options_count, optionCount) + Surface( + modifier = + Modifier.semantics { + contentDescription = optionCountDescription + }, + color = WsAccent.copy(alpha = 0.14f), + shape = RoundedCornerShape(7.dp), + ) { + Text( + optionCount.toString(), + color = WsAccentGlow, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 9.dp, vertical = 3.dp), + ) + } + IconButton(onClick = onClose, modifier = Modifier.size(36.dp).paneNavItem(onActivate = onClose)) { + Icon( + Icons.Outlined.Close, + contentDescription = stringResource(R.string.common_ui_close), + tint = WsTextSecondary, + modifier = Modifier.size(20.dp), + ) + } + } +} + +@Composable +private fun LaunchOptionPickerRow( + option: StoreLaunchOptionItem, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .paneNavItem(onActivate = onClick, tapToSelect = true) + .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) WsAccentGlow else WsTextPrimary, + 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 = WsTextSecondary, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (selected) { + Icon( + Icons.Outlined.Check, + contentDescription = null, + tint = WsAccentGlow, + modifier = Modifier.size(18.dp), + ) + } + } +} + +// Apps already PICS-refreshed this process — avoids a round-trip per game-detail open. +private val launchOptionsRefreshedApps = java.util.Collections.synchronizedSet(mutableSetOf()) + +/** UI state for the launch-options picker, shared by both game-detail screens. */ +@Stable +internal class SteamLaunchOptionsState { + var options by mutableStateOf>(emptyList()) + var selected by mutableStateOf(null) + var showDialog by mutableStateOf(false) + var reloadToken by mutableStateOf(0) + private set + + fun show() { + reloadToken++ + showDialog = true + } + + fun dismiss() { + showDialog = false + } +} + +/** Loads the launch options from cache while [enabled]; the PICS heal rides picker opens. */ +@Composable +internal fun rememberSteamLaunchOptionsState( + appId: Int, + enabled: Boolean, +): SteamLaunchOptionsState { + val appContext = LocalContext.current.applicationContext + val state = remember(appId) { SteamLaunchOptionsState() } + // reloadToken key: re-read from disk on every picker open (not close) — shortcut + // settings can change the extras behind this screen's back. + LaunchedEffect(appId, enabled, state.reloadToken) { + val ready = enabled && withContext(Dispatchers.IO) { SteamService.isAppInstalled(appId) } + if (!ready) { + state.options = emptyList() + state.selected = null + return@LaunchedEffect + } + val (options, selected) = loadSteamLaunchOptions(appContext, appId) + state.options = options + state.selected = selected + // Cache-only until the picker is opened (reloadToken > 0), so browsing game + // screens never hits the network; the once-per-process PICS heal runs on first open. + if (state.reloadToken > 0 && launchOptionsRefreshedApps.add(appId)) { + if (SteamService.refreshAppInfoFromPics(appId)) { + val (fresh, freshSelected) = loadSteamLaunchOptions(appContext, appId) + state.options = fresh + state.selected = freshSelected + } else { + // Offline or fetch failed — allow a retry on the next open. + launchOptionsRefreshedApps.remove(appId) + } + } + } + return state +} + +/** Hosts the launch-option picker over a game-detail screen; persists the tapped row. */ +@Composable +internal fun SteamLaunchOptionsDialogHost( + appId: Int, + gameTitle: String, + state: SteamLaunchOptionsState, +) { + val context = LocalContext.current + // Scope outlives the dialog so a tap-then-dismiss still persists and reports failures. + val scope = rememberCoroutineScope() + if (!state.showDialog) return + Dialog( + onDismissRequest = state::dismiss, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + StoreLaunchOptionsScreen( + gameTitle = gameTitle, + options = state.options, + selectedOption = state.selected, + onSelect = { option -> + persistSteamLaunchOptionSelection(context, appId, option, scope) { state.selected = it } + }, + onClose = state::dismiss, + ) + } +} + +/** Launch-option list (appinfo config.launch) plus the currently effective selection. */ +private suspend fun loadSteamLaunchOptions( + context: Context, + 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 kept in the key: stale cached rows have "" args and would + // collapse distinct options like "Play (DX11)" / "Play (DX12)". + .distinctBy { Triple(SteamService.normalizeRelativeExe(it.executable).lowercase(), it.arguments, it.label) } + // Hide options whose exe is missing on disk; case-insensitive to match how + // the launch path resolves these same appinfo paths against the depot files. + val onDisk = allOptions.filter { SteamService.fileExistsIgnoreCase(appDir.path, it.executable) } + val options = onDisk.ifEmpty { allOptions } + // Exact match against the explicitly picked option, or no checkmark at all — + // a manually configured exe is never adopted as a launch option. + val selected = + SteamService.getSelectedLaunchOption(context, appId)?.let { (exe, args) -> + // Normalized like every persist/launch-side compare (separators, drive prefix). + val selectedExe = SteamService.normalizeRelativeExe(exe) + options.firstOrNull { + SteamService.normalizeRelativeExe(it.executable).equals(selectedExe, ignoreCase = true) && + it.arguments == args + } + } + options to selected + } + +private fun persistSteamLaunchOptionSelection( + context: Context, + appId: Int, + option: StoreLaunchOptionItem, + scope: CoroutineScope, + onSaved: (StoreLaunchOptionItem) -> Unit, +) { + scope.launch(Dispatchers.IO) { + val saved = + SteamService.setSelectedLaunchOption( + context.applicationContext, + appId, + option.executable, + option.arguments, + ) + withContext(Dispatchers.Main) { + if (saved) { + onSaved(option) + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + context.getString(R.string.store_game_launch_option_failed), + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } +} diff --git a/app/src/main/app/shell/LibraryGameLaunchScreen.kt b/app/src/main/app/shell/LibraryGameLaunchScreen.kt index 66f62fb8f..0b37df3e7 100644 --- a/app/src/main/app/shell/LibraryGameLaunchScreen.kt +++ b/app/src/main/app/shell/LibraryGameLaunchScreen.kt @@ -50,6 +50,7 @@ import androidx.compose.material.icons.outlined.DesktopWindows import androidx.compose.material.icons.outlined.EmojiEvents 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 @@ -140,6 +141,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, @@ -278,11 +281,13 @@ internal fun LibraryGameLaunchScreen( showCheckForUpdate = showCheckForUpdate, showWorkshop = showWorkshop, showAchievements = onAchievements != null, + showLaunchOptions = showLaunchOptions, areSteamActionsEnabled = areSteamActionsEnabled, onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, onAchievements = { onAchievements?.invoke() }, + onLaunchOptions = onLaunchOptions, ) } @@ -821,11 +826,13 @@ private fun SourceTag( showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, showAchievements: Boolean = false, + showLaunchOptions: Boolean = false, areSteamActionsEnabled: Boolean = true, onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, onAchievements: () -> Unit = {}, + onLaunchOptions: () -> Unit = {}, ) { var menuOpen by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableStateOf(0) } @@ -902,6 +909,13 @@ private fun SourceTag( label = stringResource(R.string.steam_achievements_title), ) { menuOpen = false; onAchievements() } } + 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 7697cf1bc..17ea128ee 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 @@ -155,6 +156,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, @@ -202,7 +205,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 || @@ -313,6 +318,7 @@ internal fun StoreGameDetailScreen( showCheckForUpdate = updateCheckAvailable, showVerifyFiles = verifyFilesAvailable, showWorkshop = workshopAvailable, + showLaunchOptions = launchOptionsAvailable, isCheckingForUpdate = isCheckingForUpdate, areSteamActionsEnabled = areSteamActionsEnabled, isUpdateCheckEnabled = @@ -323,6 +329,7 @@ internal fun StoreGameDetailScreen( onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, + onLaunchOptions = onLaunchOptions, ) } @@ -824,12 +831,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 anchorHeightPx by remember { mutableIntStateOf(0) } Box { @@ -913,6 +922,13 @@ private fun StoreSourceTag( enabled = areSteamActionsEnabled, ) { onMenuOpenChange(false); onWorkshop() } } + if (showLaunchOptions) { + StoreSourceMenuItem( + icon = Icons.Outlined.RocketLaunch, + label = stringResource(R.string.store_game_launch_options), + enabled = areSteamActionsEnabled, + ) { onMenuOpenChange(false); onLaunchOptions() } + } } } } diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 2969eae93..29721e6cd 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -5656,6 +5656,9 @@ 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 + + val launchOptionsState = rememberSteamLaunchOptionsState(app.id, enabled = isSteamLibraryGame) val libraryDownloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState( initial = com.winlator.cmod.app.service.download.DownloadCoordinator.snapshotRecords(), @@ -6387,6 +6390,8 @@ class UnifiedActivity : (!isEpic || epicGame?.isInstalled == true) && (!isGog || gogGame?.isInstalled == true), showWorkshop = !isEpic && !isGog, + showLaunchOptions = launchOptionsState.options.size >= 2, + onLaunchOptions = launchOptionsState::show, areSteamActionsEnabled = when { isEpic -> !hasBlockingEpicDownloadForLibrary @@ -6885,6 +6890,8 @@ class UnifiedActivity : onDismissRequest = { showWorkshopDialog = false }, ) } + + SteamLaunchOptionsDialogHost(app.id, app.name, launchOptionsState) } } } @@ -9936,6 +9943,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) } + val launchOptionsState = rememberSteamLaunchOptionsState(app.id, enabled = installed == true) 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( @@ -10122,6 +10130,8 @@ class UnifiedActivity : showWorkshop = isReallyInstalled, showVerifyFiles = isReallyInstalled, areSteamActionsEnabled = !hasBlockingSteamDownload, + showLaunchOptions = launchOptionsState.options.size >= 2, + onLaunchOptions = launchOptionsState::show, dlcs = dlcItems, selectedDlcIds = selectedDlcIds.toSet(), isDlcSelectionEnabled = steamDownloadRecord == null, @@ -10261,6 +10271,8 @@ class UnifiedActivity : onDismissRequest = { showWorkshopDialog = false }, ) } + + SteamLaunchOptionsDialogHost(app.id, app.name, launchOptionsState) } @Composable diff --git a/app/src/main/assets/wnsteam/bionic/steam.exe b/app/src/main/assets/wnsteam/bionic/steam.exe index 7260a95f5..1050f632d 100755 Binary files a/app/src/main/assets/wnsteam/bionic/steam.exe and b/app/src/main/assets/wnsteam/bionic/steam.exe differ diff --git a/app/src/main/cpp/wn-steam-launcher/src/main.cpp b/app/src/main/cpp/wn-steam-launcher/src/main.cpp index e452cf14a..5f2748474 100644 --- a/app/src/main/cpp/wn-steam-launcher/src/main.cpp +++ b/app/src/main/cpp/wn-steam-launcher/src/main.cpp @@ -304,18 +304,90 @@ static int count_game_processes(const char* exeName) { return count; } +static bool append_char(char* buf, size_t cap, size_t* n, char c) { + if (*n + 1 >= cap) return false; + buf[(*n)++] = c; + return true; +} + +// Appends one argv element re-quoted per CommandLineToArgvW rules (backslash runs +// before an emitted quote must be doubled); false when the buffer would overflow. +static bool append_quoted_arg(char* buf, size_t cap, size_t* n, const char* a) { + const bool quote = (a[0] == '\0') || strpbrk(a, " \t\"") != NULL; + if (!quote) { + for (const char* p = a; *p; ++p) + if (!append_char(buf, cap, n, *p)) return false; + return true; + } + if (!append_char(buf, cap, n, '"')) return false; + const char* p = a; + while (*p) { + size_t backslashes = 0; + while (*p == '\\') { backslashes++; p++; } + if (*p == '\0') { + // Run ends the arg: double it so the closing quote stays a delimiter. + for (size_t k = 0; k < backslashes * 2; k++) + if (!append_char(buf, cap, n, '\\')) return false; + break; + } + if (*p == '"') { + for (size_t k = 0; k < backslashes * 2 + 1; k++) + if (!append_char(buf, cap, n, '\\')) return false; + } else { + for (size_t k = 0; k < backslashes; k++) + if (!append_char(buf, cap, n, '\\')) return false; + } + if (!append_char(buf, cap, n, *p)) return false; + p++; + } + return append_char(buf, cap, n, '"'); +} + +// Re-joins argv[2..] for forwarding onto the game's command line; on overflow +// whole trailing args are dropped (never a half token) with a logged warning. +static const char* build_extra_args(int argc, char** argv) { + static char buf[4096]; + size_t n = 0; + buf[0] = '\0'; + for (int i = 2; i < argc; i++) { + size_t before = n; + bool ok = (n == 0) || append_char(buf, sizeof(buf), &n, ' '); + ok = ok && append_quoted_arg(buf, sizeof(buf), &n, argv[i]); + if (!ok) { + n = before; + log_line("[wn-launcher] extra args exceed %zu bytes - dropped %d trailing arg(s)", + sizeof(buf), argc - i); + break; + } + } + buf[n] = '\0'; + return buf; +} + // Direct launch when LaunchApp dispatches cleanly but never spawns the game (no // real Steam UI/reaper under Wine to consume the request). Safe vs AlreadyRunning // because the clean-shutdown arm reaps the CM session on exit. Logs the "game // process started pid=" marker WnLauncherStatusTailer treats as launch-complete. -static bool create_process_game(const char* gameExe, const char* exeName) { +static bool create_process_game(const char* gameExe, const char* exeName, + const char* extraArgs) { char cwd[MAX_PATH]; snprintf(cwd, sizeof(cwd), "%s", gameExe); char* slash = strrchr(cwd, '\\'); if (slash) *slash = '\0'; else cwd[0] = '\0'; - char cmd[MAX_PATH + 8]; - snprintf(cmd, sizeof(cmd), "\"%s\"", gameExe); + char cmd[MAX_PATH + 4096 + 16]; + int written; + if (extraArgs && extraArgs[0]) { + written = snprintf(cmd, sizeof(cmd), "\"%s\" %s", gameExe, extraArgs); + } else { + written = snprintf(cmd, sizeof(cmd), "\"%s\"", gameExe); + } + if (written < 0 || written >= (int) sizeof(cmd)) { + log_line("[wn-launcher] CreateProcess cmd truncated (%d bytes)", written); + } + // Args content stays out of the log — execArgs can carry +password style credentials. + log_line("[wn-launcher] CreateProcess exe=\"%s\" argsLen=%zu", + gameExe, (extraArgs && extraArgs[0]) ? strlen(extraArgs) : (size_t) 0); STARTUPINFOA si; memset(&si, 0, sizeof(si)); @@ -758,13 +830,15 @@ int main(int argc, char** argv) { const char* token = getenv("WN_STEAM_TOKEN"); uint64_t steamId = env_u64("WN_STEAM_STEAMID"); const char* gameExe = (argc > 1) ? argv[1] : NULL; + const char* extraArgs = build_extra_args(argc, argv); uint32_t appId = appIdStr ? (uint32_t) strtoul(appIdStr, NULL, 10) : 0; - log_line("[wn-launcher] env appId=%u steamId=%llu user=%s exe=%s", + log_line("[wn-launcher] env appId=%u steamId=%llu user=%s exe=%s argsLen=%zu", appId, (unsigned long long) steamId, user ? user : "(null)", - gameExe ? gameExe : "(null)"); + gameExe ? gameExe : "(null)", + strlen(extraArgs)); if (token && *token) { size_t tokenLen = strlen(token); log_line("[wn-launcher] token len=%zu prefix=%.*s suffix=%.*s", @@ -1393,7 +1467,7 @@ int main(int argc, char** argv) { "— falling back to CreateProcess (%s)", exeName, launchFailureReason); } - launchedViaFallback = create_process_game(gameExe, exeName); + launchedViaFallback = create_process_game(gameExe, exeName, extraArgs); } if (launchedViaApp || launchedViaFallback) { diff --git a/app/src/main/feature/library/GameSettings.kt b/app/src/main/feature/library/GameSettings.kt index c1b7d0f05..dbe5b6ecf 100644 --- a/app/src/main/feature/library/GameSettings.kt +++ b/app/src/main/feature/library/GameSettings.kt @@ -344,6 +344,9 @@ class GameSettingsStateHolder { val name = mutableStateOf("") val launchExePath = mutableStateOf("") val launchExeDisplayPath = mutableStateOf("") + + // Read-only: args the selected Steam launch option appends at launch (display only). + val launchOptionArgs = mutableStateOf("") val containerEntries = mutableStateOf>(emptyList()) val selectedContainer = mutableIntStateOf(0) val screenSizeEntries = mutableStateOf>(emptyList()) @@ -3670,6 +3673,15 @@ private fun AdvancedSection( ) } + if (state.launchOptionArgs.value.isNotBlank()) { + Spacer(Modifier.height(SettingTightGap)) + Text( + stringResource(R.string.shortcut_launch_option_args, state.launchOptionArgs.value), + color = TextDim, + fontSize = 10.sp + ) + } + Spacer(Modifier.height(SettingItemGap)) SettingCheckbox( diff --git a/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt b/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt index 393f670b2..74e3e0858 100644 --- a/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt +++ b/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt @@ -49,6 +49,7 @@ import com.winlator.cmod.feature.settings.GraphicsDriverConfigUtils import com.winlator.cmod.feature.settings.WineD3DConfigUtils import com.winlator.cmod.feature.setup.SetupWizardActivity import com.winlator.cmod.feature.stores.steam.events.AndroidEvent +import com.winlator.cmod.feature.stores.steam.service.SteamService import com.winlator.cmod.runtime.compat.box64.Box64Preset import com.winlator.cmod.runtime.compat.box64.Box64PresetManager import com.winlator.cmod.runtime.container.Container @@ -358,6 +359,7 @@ class ShortcutSettingsComposeDialog private constructor( state.name.value = shortcut.name state.launchExePath.value = resolveInitialLaunchExePath() state.launchExeDisplayPath.value = resolveLaunchExeDisplayPath(state.launchExePath.value) + state.launchOptionArgs.value = shortcut.getExtra("launch_exe_args") syncLibraryArtworkState() val inputType = Integer.parseInt( @@ -1203,6 +1205,14 @@ class ShortcutSettingsComposeDialog private constructor( // Launch EXE path val launchExePath = normalizeLaunchExeForShortcut(state.launchExePath.value) if (launchExePath.isNotEmpty()) { + val storedExePath = SteamService.normalizeRelativeExe(shortcut.getExtra("launch_exe_path")) + // A manual exe CHANGE turns the Steam launch option off ("" marker, not key removal). + // Normalized compare: appinfo '\'/case variants of the same exe must not false-trigger. + if (!SteamService.normalizeRelativeExe(launchExePath).equals(storedExePath, ignoreCase = true)) { + shortcut.putExtra("launch_exe_args", null) + shortcut.putExtra("launch_option_exe", "") + shortcut.putExtra("launch_option_args", null) + } shortcut.putExtra("launch_exe_path", launchExePath) val gameSource = shortcut.getExtra("game_source", "") if (gameSource == "CUSTOM") { 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 08b85871e..d8175ae72 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -89,6 +89,7 @@ import com.winlator.cmod.feature.steamcloudsync.SteamAutoCloud import com.winlator.cmod.feature.sync.google.CloudSyncManager import com.winlator.cmod.runtime.container.Container import com.winlator.cmod.runtime.container.ContainerManager +import com.winlator.cmod.runtime.container.Shortcut import com.winlator.cmod.runtime.display.environment.ImageFs import com.winlator.cmod.runtime.system.GPUInformation import com.winlator.cmod.runtime.system.SessionKeepAliveService @@ -2242,6 +2243,118 @@ class SteamService : Service() { return container.executablePath.ifEmpty { getInstalledExe(gameId) } } + private data class SteamShortcutHit( + val extras: Map, + val containerDir: File, + val desktopFile: File, + ) + + /** + * Reads the shortcut's `[Extra Data]` straight from its .desktop file — Shortcut + * construction decodes bitmaps and can rewrite the file, too heavy for read paths. + */ + private fun readSteamShortcutExtras( + context: Context, + appId: Int, + ): SteamShortcutHit? { + 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) { + // Shortcut owns the .desktop parse — same map the launch path sees via getExtra(). + val extras = Shortcut.parseExtras(file) + if (extras["game_source"] == "STEAM" && extras["app_id"] == appIdStr) { + return SteamShortcutHit(extras, containerDir, file) + } + } + } + return null + } + + /** Constructs just the matching shortcut — loadShortcuts() would decode every shortcut's icon. */ + private fun locateSteamShortcut( + context: Context, + appId: Int, + ): Shortcut? { + val hit = readSteamShortcutExtras(context, appId) ?: return null + val containerId = hit.containerDir.name.removePrefix("${ImageFs.USER}-").toIntOrNull() ?: return null + val container = ContainerManager(context).getContainerById(containerId) ?: return null + return Shortcut(container, hit.desktopFile) + } + + /** Exe path normalized ('/' separators, drive prefix stripped) — the one normalizer for exe compares. */ + @JvmStatic + fun normalizeRelativeExe(path: String): String { + val n = path.replace('\\', '/').trim().trimStart('/') + val hasDrive = n.length > 2 && (n[0] in 'A'..'Z' || n[0] in 'a'..'z') && n[1] == ':' && n[2] == '/' + return if (hasDrive) n.substring(3) else n + } + + /** + * Persists the pick on the shortcut + container; Steam's default entry clears the args + * override so launch returns to LaunchApp. Call on IO dispatcher. + */ + fun setSelectedLaunchOption( + context: Context, + appId: Int, + executable: String, + arguments: String, + ): Boolean { + var shortcut = locateSteamShortcut(context, appId) + if (shortcut == null) { + // Installs from older builds may predate shortcut creation on download. + createSteamShortcut(context, appId) + shortcut = locateSteamShortcut(context, appId) + } + if (shortcut == null) { + Timber.w("setSelectedLaunchOption: no shortcut for appId=$appId") + return false + } + val default = getWindowsLaunchInfos(appId).firstOrNull() + val isDefault = + default != null && + normalizeRelativeExe(executable).equals(normalizeRelativeExe(default.executable), ignoreCase = true) && + arguments.trim() == default.arguments.trim() + shortcut.putExtra("launch_exe_path", executable) + shortcut.putExtra("launch_exe_args", if (isDefault) null else arguments.ifBlank { null }) + // Explicit picker selection, stored verbatim — display state, separate from the + // launch-side extras above. A manual exe change removes these keys (option off). + shortcut.putExtra("launch_option_exe", executable) + shortcut.putExtra("launch_option_args", arguments) + shortcut.saveData() + shortcut.container?.let { + it.executablePath = executable + it.saveData() + } + return true + } + + /** + * The active option as (executable, arguments): the explicit pick, or the default while + * untouched; null once a manual exe change turned options off. Call on IO dispatcher. + */ + fun getSelectedLaunchOption( + context: Context, + appId: Int, + ): Pair? { + val extras = runCatching { readSteamShortcutExtras(context, appId) }.getOrNull()?.extras.orEmpty() + val picked = extras["launch_option_exe"] + if (picked != null) { + return if (picked.isBlank()) null else picked to extras["launch_option_args"].orEmpty() + } + val default = getWindowsLaunchInfos(appId).firstOrNull() ?: return null + val exePath = extras["launch_exe_path"].orEmpty() + val untouched = + exePath.isBlank() || + normalizeRelativeExe(exePath).equals(normalizeRelativeExe(default.executable), ignoreCase = true) + return if (untouched) default.executable to default.arguments else null + } + suspend fun deleteApp(appId: Int): Boolean = withContext(Dispatchers.IO) { val appDirPath = getAppDirPath(appId) @@ -2764,6 +2877,12 @@ class SteamService : Service() { } } + /** True when [relativePath] is a file under [baseDirPath], matching segments case-insensitively. */ + fun fileExistsIgnoreCase( + baseDirPath: String, + relativePath: String, + ): Boolean = resolvePathCaseInsensitive(baseDirPath, relativePath)?.isFile == true + private fun resolvePathCaseInsensitive( baseDirPath: String, relativePath: String, @@ -4515,7 +4634,7 @@ class SteamService : Service() { ?.let { appInfo -> appInfo.config.launch.filter { launchInfo -> // since configOS was unreliable and configArch was even more unreliable - launchInfo.executable.endsWith(".exe") + launchInfo.executable.endsWith(".exe", ignoreCase = true) } }.orEmpty() @@ -7283,6 +7402,19 @@ class SteamService : Service() { return null } + /** Re-fetches one app's PICS appinfo to heal cached rows predating newly parsed fields. */ + 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/SteamLaunchOptions.kt b/app/src/main/feature/stores/steam/utils/SteamLaunchOptions.kt index d1668b534..01663b485 100644 --- a/app/src/main/feature/stores/steam/utils/SteamLaunchOptions.kt +++ b/app/src/main/feature/stores/steam/utils/SteamLaunchOptions.kt @@ -31,6 +31,26 @@ object SteamLaunchOptions { @JvmStatic fun parseEnvVars(execArgs: String?): Map = parse(execArgs).env + /** + * Splices a launch option's own args (`launch_exe_args`) into the user's custom args, + * honoring the `%command%` placeholder so wrappers like `FOO=1 %command%` keep 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" + } + } + private fun tokenize(s: String): List { val out = ArrayList() val sb = StringBuilder() diff --git a/app/src/main/feature/stores/steam/utils/SteamUtils.kt b/app/src/main/feature/stores/steam/utils/SteamUtils.kt index 0b022ffa0..9857ebcde 100644 --- a/app/src/main/feature/stores/steam/utils/SteamUtils.kt +++ b/app/src/main/feature/stores/steam/utils/SteamUtils.kt @@ -1399,39 +1399,45 @@ object SteamUtils { } } + // Per-process native state, like the pushed command line — re-push on warm launches too. + @JvmStatic + fun pushAppFamilyShared(appId: String) { + val appIdInt = appId.toIntOrNull() ?: 0 + val familyShared: Boolean = + if (appIdInt <= 0) { + false + } else { + val license = SteamService.getPkgInfoOf(appIdInt) + val selfAcct = PrefManager.steamUserAccountId + val owners = license?.ownerAccountId.orEmpty() + when { + owners.isEmpty() -> false + owners.contains(selfAcct) -> false // direct + owners.any { it in SteamService.familyMembers } -> true + else -> false + } + } + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient + .setAppFamilyShared(familyShared) + if (familyShared) { + Timber.i("Bound app $appId is family-shared (owner outside self)") + } + } + @JvmStatic fun updateOrModifyLocalConfig( imageFs: ImageFs, container: Container, appId: String, steamUserId64: String, + // Pre-combined effective line (selected option args + custom args) — see combineSteamLaunchArgs. + exeCommandLine: String = container.execArgs, ) { try { - val exeCommandLine = container.execArgs - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient .setLaunchCommandLine(exeCommandLine) - val appIdInt = appId.toIntOrNull() ?: 0 - val familyShared: Boolean = - if (appIdInt <= 0) { - false - } else { - val license = SteamService.getPkgInfoOf(appIdInt) - val selfAcct = PrefManager.steamUserAccountId - val owners = license?.ownerAccountId.orEmpty() - when { - owners.isEmpty() -> false - owners.contains(selfAcct) -> false // direct - owners.any { it in SteamService.familyMembers } -> true - else -> false - } - } - com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient - .setAppFamilyShared(familyShared) - if (familyShared) { - Timber.i("Bound app $appId is family-shared (owner outside self)") - } + pushAppFamilyShared(appId) val steamPath = File(imageFs.wineprefix, "drive_c/Program Files (x86)/Steam") val userDataPath = File(steamPath, "userdata/$steamUserId64") diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index 4f146e6a8..3af80339b 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -113,6 +113,10 @@ Kunne ikke tjekke for opdatering Opdater Workshop + Startindstillinger + Kunne ikke gemme startindstillingen + %1$d startindstillinger + Startindstillingens argumenter: %1$s Bekræft filer Bekræfter %1$s — se fanen Downloads Steam-indstillinger diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 3db58b93d..dbc4d8b6b 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -113,6 +113,10 @@ Update-Prüfung fehlgeschlagen Aktualisieren Workshop + Startoptionen + Startoption konnte nicht gespeichert werden + %1$d Startoptionen + Argumente der Startoption: %1$s Dateien prüfen %1$s wird geprüft — siehe Downloads-Tab Steam-Optionen diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index f173826f6..7069480df 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -113,6 +113,10 @@ Error al comprobar actualizaciones Actualizar Workshop + Opciones de lanzamiento + No se pudo guardar la opción de lanzamiento + %1$d opciones de lanzamiento + Argumentos de la opción de lanzamiento: %1$s Verificar archivos Verificando %1$s — revisa la pestaña Descargas Opciones de Steam diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 15d7a9801..c053bf7d4 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -113,6 +113,10 @@ Échec de la vérification des mises à jour Mettre à jour Workshop + Options de lancement + Impossible d\'enregistrer l\'option de lancement + %1$d options de lancement + Arguments de l\'option de lancement : %1$s Vérifier les fichiers Vérification de %1$s — consultez l\'onglet Téléchargements Options Steam diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 73340af82..64eab907f 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -114,6 +114,10 @@ अपडेट जाँच विफल रही अपडेट वर्कशॉप + लॉन्च विकल्प + लॉन्च विकल्प सहेजा नहीं जा सका + %1$d लॉन्च विकल्प + लॉन्च विकल्प के आर्गुमेंट: %1$s फ़ाइलें सत्यापित करें %1$s का सत्यापन - डाउनलोड टैब की जाँच करें Steam विकल्प diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 8ce77f6ba..df2d90503 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -113,6 +113,10 @@ Controllo aggiornamenti non riuscito Aggiorna Workshop + Opzioni di avvio + Impossibile salvare l\'opzione di avvio + %1$d opzioni di avvio + Argomenti dell\'opzione di avvio: %1$s Verifica file Verifica di %1$s — controlla la scheda Download Opzioni Steam diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index d3e12a025..e367b40a2 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -113,6 +113,10 @@ 업데이트 확인 실패 업데이트 창작마당 + 실행 옵션 + 실행 옵션을 저장하지 못했습니다 + 실행 옵션 %1$d개 + 실행 옵션 인수: %1$s 파일 확인 %1$s 확인 중 — 다운로드 탭을 확인하세요 Steam 옵션 diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 98613951f..f10aaa4a3 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -113,6 +113,10 @@ Nie udało się sprawdzić aktualizacji Aktualizuj Workshop + Opcje uruchamiania + Nie udało się zapisać opcji uruchamiania + Opcje uruchamiania: %1$d + Argumenty opcji uruchamiania: %1$s Zweryfikuj pliki Weryfikowanie %1$s — sprawdź kartę Pobrane Opcje Steam diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 7bd36eee2..276b88cf5 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -113,6 +113,10 @@ Falha ao verificar atualização Atualizar Workshop + Opções de inicialização + Não foi possível salvar a opção de inicialização + %1$d opções de inicialização + Argumentos da opção de inicialização: %1$s Verificar arquivos Verificando %1$s — confira a aba Downloads Opções do Steam diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 8411c5bc1..d1c2a09c0 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -113,6 +113,10 @@ Verificarea actualizărilor a eșuat Actualizează Workshop + Opțiuni de lansare + Nu s-a putut salva opțiunea de lansare + %1$d opțiuni de lansare + Argumentele opțiunii de lansare: %1$s Verifică fișierele Se verifică %1$s — verifică fila Descărcări Opțiuni Steam diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 392f3549a..9122bfc74 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -114,6 +114,10 @@ Не удалось проверить обновления Обновить Мастерская + Параметры запуска + Не удалось сохранить параметр запуска + Параметров запуска: %1$d + Аргументы параметра запуска: %1$s Проверить файлы Проверка %1$s — смотрите вкладку «Загрузки». Параметры Steam diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 45c92c010..2db58344e 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -113,6 +113,10 @@ Не вдалося перевірити оновлення Оновити Workshop + Параметри запуску + Не вдалося зберегти параметр запуску + Параметрів запуску: %1$d + Аргументи параметра запуску: %1$s Перевірити файли Перевірка %1$s — дивіться вкладку Завантаження Параметри Steam diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 20e4aec3e..dd0b748f6 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -113,6 +113,10 @@ 更新检查失败 更新 创意工坊 + 启动选项 + 无法保存启动选项 + %1$d 个启动选项 + 启动选项参数:%1$s 验证文件 正在验证 %1$s — 请查看下载标签页 Steam 选项 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 90001b4e8..d4bb75659 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -113,6 +113,10 @@ 更新檢查失敗 更新 工作坊 + 啟動選項 + 無法儲存啟動選項 + %1$d 個啟動選項 + 啟動選項參數:%1$s 驗證檔案 正在驗證 %1$s — 請查看下載分頁 Steam 選項 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bf004118b..19f9e689e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -116,6 +116,10 @@ Update check failed Update Workshop + Launch Options + Couldn\'t save launch option + %1$d launch options + Launch option arguments: %1$s Verify Files Verifying %1$s — check the Downloads tab Steam options diff --git a/app/src/main/runtime/container/Shortcut.java b/app/src/main/runtime/container/Shortcut.java index 8966ac0cf..8f2e7262d 100644 --- a/app/src/main/runtime/container/Shortcut.java +++ b/app/src/main/runtime/container/Shortcut.java @@ -12,7 +12,9 @@ import java.nio.file.Files; import java.util.ArrayList; import java.util.Iterator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.UUID; import org.json.JSONException; import org.json.JSONObject; @@ -33,6 +35,28 @@ public class Shortcut { private static final String COVER_ART_DIR = "app_data/cover_arts/"; // Removed leading "/" to keep it relative + /** + * Extras-only parse of a .desktop file's [Extra Data] section; keep in sync with the + * constructor's section handling. Never throws, so a corrupt file can't abort callers' scans. + */ + public static Map parseExtras(File desktopFile) { + Map extras = new LinkedHashMap<>(); + String section = ""; + for (String line : FileUtils.readLines(desktopFile)) { + line = line.trim(); + if (line.isEmpty() || line.startsWith("#")) continue; + if (line.startsWith("[")) { + int end = line.indexOf("]"); + section = end > 0 ? line.substring(1, end) : ""; + } else if (section.equals("Extra Data")) { + int index = line.indexOf("="); + if (index <= 0) continue; + extras.put(line.substring(0, index), line.substring(index + 1)); + } + } + return extras; + } + public Shortcut(Container container, File file) { this.container = container; this.file = file; diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index cf4a7d7f4..288a7f054 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -50,6 +50,7 @@ import com.winlator.cmod.BuildConfig; import com.winlator.cmod.feature.leaderboard.SessionRecordingController; import com.winlator.cmod.feature.stores.steam.enums.Marker; +import com.winlator.cmod.feature.stores.steam.service.SteamService; import com.winlator.cmod.feature.stores.steam.utils.MarkerUtils; import com.winlator.cmod.feature.stores.steam.utils.PrefManager; import com.winlator.cmod.feature.stores.steam.utils.SteamUtils; @@ -2085,17 +2086,10 @@ private boolean isSuspiciousSteamInstallLeaf(String value) { return "common".equalsIgnoreCase(normalized) || "steamapps".equalsIgnoreCase(normalized); } + /** Delegates to the shared normalizer so the persist and launch sides compare identically. */ private String normalizeRelativeExeCandidate(String value) { if (value == null) return ""; - - String normalized = value.trim().replace('\\', '/'); - while (normalized.startsWith("/")) { - normalized = normalized.substring(1); - } - if (normalized.matches("^[A-Za-z]:/.*")) { - normalized = normalized.substring(3); - } - return normalized; + return SteamService.normalizeRelativeExe(value); } private File resolveImmediateChildCaseInsensitive(File parent, String childName) { @@ -8173,7 +8167,8 @@ private String getWineStartCommand(GuestProgramLauncherComponent launcherCompone wnSteamDirectExeOverride = false; String steamExtraArgs = appendSteamJoinConnect( com.winlator.cmod.feature.stores.steam.utils.SteamLaunchOptions - .gameArgs(shortcut.getSettingExtra("execArgs", container.getExecArgs()))); + .gameArgs(combineWithSelectedLaunchArgs( + shortcut.getSettingExtra("execArgs", container.getExecArgs())))); steamExtraArgs = (steamExtraArgs != null && !steamExtraArgs.isEmpty()) ? " " + steamExtraArgs : ""; boolean useColdClient = parseBoolean(getShortcutSetting("useColdClient", container.isUseColdClient() ? "1" : "0")); @@ -8223,8 +8218,9 @@ private String getWineStartCommand(GuestProgramLauncherComponent launcherCompone // Goldberg launches through steamapps/common to avoid drive-letter drift. String gameDirName = (gameInstPath != null) ? new File(gameInstPath).getName() : ""; String relativeExe = resolveRelativeGameExe(appId, gameInstPath); - // If the resolved exe isn't Steam's configured launch entry the user overrode it; tell the launcher to skip LaunchApp and start the selected exe directly. - wnSteamDirectExeOverride = isUserOverriddenSteamExe(appId, relativeExe); + // Skip LaunchApp when the user overrode the exe OR picked a launch option with args — LaunchApp would start Steam's default entry, ignoring (or doubling) the selection. + wnSteamDirectExeOverride = isUserOverriddenSteamExe(appId, relativeExe) + || !shortcut.getExtra("launch_exe_args").isEmpty(); if (!relativeExe.isEmpty() && !gameDirName.isEmpty()) { String steamGameExe = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\" @@ -8558,7 +8554,7 @@ private void writeColdClientIniDirect(int appId, String gameDirName, String rela } String perGameExecArgs = shortcut != null ? shortcut.getSettingExtra("execArgs", container.getExecArgs()) : container.getExecArgs(); - String exeCommandLine = appendSteamJoinConnect(com.winlator.cmod.feature.stores.steam.utils.SteamLaunchOptions.gameArgs(perGameExecArgs)); + String exeCommandLine = appendSteamJoinConnect(com.winlator.cmod.feature.stores.steam.utils.SteamLaunchOptions.gameArgs(combineWithSelectedLaunchArgs(perGameExecArgs))); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); @@ -8643,7 +8639,7 @@ private void writeColdClientIniForLaunch(int appId, String gameInstallPath, Stri } String perGameExecArgs = shortcut != null ? shortcut.getSettingExtra("execArgs", container.getExecArgs()) : container.getExecArgs(); - String exeCommandLine = appendSteamJoinConnect(com.winlator.cmod.feature.stores.steam.utils.SteamLaunchOptions.gameArgs(perGameExecArgs)); + String exeCommandLine = appendSteamJoinConnect(com.winlator.cmod.feature.stores.steam.utils.SteamLaunchOptions.gameArgs(combineWithSelectedLaunchArgs(perGameExecArgs))); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); @@ -8747,19 +8743,20 @@ private boolean isUserOverriddenSteamExe(int appId, String resolvedRelativeExe) if (resolvedRelativeExe == null || resolvedRelativeExe.isEmpty()) return false; String steamDefaultExe = SteamBridge.getInstalledExe(appId); if (steamDefaultExe == null || steamDefaultExe.isEmpty()) return false; - String resolved = exeBaseName(resolvedRelativeExe); - String configured = exeBaseName(steamDefaultExe); + // Full-path compare: entries may differ only by directory (x64/ vs x86/ builds). + String resolved = normalizeRelativeExeCandidate(resolvedRelativeExe); + String configured = normalizeRelativeExeCandidate(steamDefaultExe); return !resolved.isEmpty() && !configured.isEmpty() && !resolved.equalsIgnoreCase(configured); } - /** Base file name of a Windows/Unix exe path (handles both '\\' and '/' separators). */ - private static String exeBaseName(String path) { - if (path == null) return ""; - String normalized = path.replace('\\', '/'); - int slash = normalized.lastIndexOf('/'); - if (slash >= 0) normalized = normalized.substring(slash + 1); - return normalized.trim(); + /** + * Selected launch option's args ({@code launch_exe_args}) merged with the given + * custom args — the single resolver for every Steam launch channel. + */ + private String combineWithSelectedLaunchArgs(String customArgs) { + return com.winlator.cmod.feature.stores.steam.utils.SteamLaunchOptions.combineSteamLaunchArgs( + shortcut != null ? shortcut.getExtra("launch_exe_args") : "", customArgs); } private String resolveRelativeGameExe(int appId, String gameInstPath) { @@ -10311,10 +10308,13 @@ private void setupSteamEnvironment(int appId, File gameDir) { int steamAccountId = com.winlator.cmod.feature.stores.steam.utils.PrefManager.INSTANCE.getSteamUserAccountId(); String steamUserDataId = steamAccountId > 0 ? String.valueOf(steamAccountId) : steamId64; - // Stamp-cache the registry/userdata/local-config edits so warm launches skip the per-launch file-copy / VDF-parse work. Stamp key appId|userDataId — change either and it re-runs. + // Stamp-cache the registry/userdata/local-config edits so warm launches skip the per-launch file-copy / VDF-parse work. Stamp key appId|userDataId|effectiveLaunchOptions — change any and it re-runs. File steamEnvStamp = new File(winePrefix, ".wine/drive_c/.wn-steamenv-" + appId + "-" + steamUserDataId + ".stamp"); - String expectedStamp = "v1|" + appId + "|" + steamUserDataId; + // Raw effective line in the stamp: a changed selection must re-run the localconfig edit. + String effectiveLaunchOptions = combineWithSelectedLaunchArgs(container.getExecArgs()); + String expectedStamp = "v2|" + appId + "|" + steamUserDataId + + "|" + effectiveLaunchOptions; String existingStamp = steamEnvStamp.exists() ? FileUtils.readString(steamEnvStamp).trim() : ""; boolean steamEnvWarm = expectedStamp.equals(existingStamp); @@ -10329,7 +10329,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, + effectiveLaunchOptions); setupLightweightSteamConfig(steamDir, steamUserDataId); try { @@ -10339,6 +10340,10 @@ private void setupSteamEnvironment(int appId, File gameDir) { "Failed to write steam-env stamp at " + steamEnvStamp.getPath(), e); } } else { + // Command line + family-shared flag are per-process native state — re-push on warm launches. + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient.INSTANCE + .setLaunchCommandLine(effectiveLaunchOptions); + SteamUtils.pushAppFamilyShared(String.valueOf(appId)); Log.d("XServerDisplayActivity", "Steam env warm-cache hit (appId=" + appId + ", userId=" + steamUserDataId + ") — skipping reconcile + autoLogin");