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/UnifiedActivityDownloads.kt b/app/src/main/app/shell/UnifiedActivityDownloads.kt index 60e18c020..d4bf5e149 100644 --- a/app/src/main/app/shell/UnifiedActivityDownloads.kt +++ b/app/src/main/app/shell/UnifiedActivityDownloads.kt @@ -1356,6 +1356,7 @@ internal fun UnifiedActivity.GameManagerDialog( 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( @@ -1547,6 +1548,8 @@ internal fun UnifiedActivity.GameManagerDialog( showWorkshop = isReallyInstalled, showVerifyFiles = isReallyInstalled, areSteamActionsEnabled = !hasBlockingSteamDownload, + showLaunchOptions = launchOptionsState.options.size >= 2, + onLaunchOptions = launchOptionsState::show, dlcs = dlcItems, selectedDlcIds = selectedDlcIds.toSet(), isDlcSelectionEnabled = steamDownloadRecord == null, @@ -1686,6 +1689,8 @@ internal fun UnifiedActivity.GameManagerDialog( onDismissRequest = { showWorkshopDialog = false }, ) } + + SteamLaunchOptionsDialogHost(app.id, app.name, launchOptionsState) } @Composable diff --git a/app/src/main/app/shell/UnifiedActivityGameDialogs.kt b/app/src/main/app/shell/UnifiedActivityGameDialogs.kt index 8d19bc398..170b74dc7 100644 --- a/app/src/main/app/shell/UnifiedActivityGameDialogs.kt +++ b/app/src/main/app/shell/UnifiedActivityGameDialogs.kt @@ -1678,6 +1678,9 @@ internal fun UnifiedActivity.LibraryGameDetailDialog( 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(), @@ -2412,6 +2415,8 @@ internal fun UnifiedActivity.LibraryGameDetailDialog( (!isEpic || epicGame?.isInstalled == true) && (!isGog || gogGame?.isInstalled == true), showWorkshop = !isEpic && !isGog, + showLaunchOptions = launchOptionsState.options.size >= 2, + onLaunchOptions = launchOptionsState::show, areSteamActionsEnabled = when { isEpic -> !hasBlockingEpicDownloadForLibrary @@ -2910,6 +2915,8 @@ internal fun UnifiedActivity.LibraryGameDetailDialog( onDismissRequest = { showWorkshopDialog = false }, ) } + + SteamLaunchOptionsDialogHost(app.id, app.name, launchOptionsState) } } } diff --git a/app/src/main/app/shell/UnifiedActivityLaunch.kt b/app/src/main/app/shell/UnifiedActivityLaunch.kt index d7691b39b..527fb6658 100644 --- a/app/src/main/app/shell/UnifiedActivityLaunch.kt +++ b/app/src/main/app/shell/UnifiedActivityLaunch.kt @@ -270,10 +270,9 @@ internal fun UnifiedActivity.launchSteamGame( return@launch } - val shortcut = - containerManager.loadShortcuts().find { - it.getExtra("game_source") == "STEAM" && it.getExtra("app_id") == app.id.toString() - } + // Shared resolver: same canonical shortcut the picker writes to (and no + // per-shortcut icon decode the way loadShortcuts() does). + val shortcut = SteamService.locateSteamShortcut(context, app.id, containerManager) val detectedLaunchExecutable = SteamService.getInstalledExe(app.id) if (shortcut != null) { diff --git a/app/src/main/assets/wnsteam/bionic/steam.exe b/app/src/main/assets/wnsteam/bionic/steam.exe index 1b6d8a125..993566958 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 2b6445701..ef721ba0a 100644 --- a/app/src/main/cpp/wn-steam-launcher/src/main.cpp +++ b/app/src/main/cpp/wn-steam-launcher/src/main.cpp @@ -444,18 +444,118 @@ static int count_game_processes(const char* exeName) { return count; } +// Terminates game processes matching exeName, except keepPid and its children (keepPid=0 +// kills all). Reaps the LaunchApp registration spawn / a late spawn. Returns count, -1 on error. +static int kill_game_processes(const char* exeName, DWORD keepPid) { + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snap == INVALID_HANDLE_VALUE) return -1; + PROCESSENTRY32 pe; + pe.dwSize = sizeof(pe); + int killed = 0; + if (Process32First(snap, &pe)) { + do { + const bool spared = keepPid != 0 + && (pe.th32ProcessID == keepPid || pe.th32ParentProcessID == keepPid); + if (!spared && wn_game_image_matches(pe.szExeFile, exeName)) { + HANDLE h = OpenProcess(PROCESS_TERMINATE, FALSE, pe.th32ProcessID); + if (h) { + if (TerminateProcess(h, 0)) killed++; + CloseHandle(h); + } + } + } while (Process32Next(snap, &pe)); + } + CloseHandle(snap); + return killed; +} + +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 DWORD 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)) { + // A truncated cmd can end mid-token or with an unbalanced quote — launch bare instead. + log_line("[wn-launcher] CreateProcess cmd truncated (%d bytes) — " + "dropping args, launching bare exe", written); + snprintf(cmd, sizeof(cmd), "\"%s\"", gameExe); + } + // 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)); @@ -470,13 +570,14 @@ static bool create_process_game(const char* gameExe, const char* exeName) { if (!ok) { log_line("[wn-launcher] CreateProcess fallback FAILED for \"%s\" (GLE=%lu)", exeName, GetLastError()); - return false; + return 0; } + DWORD pid = pi.dwProcessId; log_line("[wn-launcher] game process started pid=%lu via CreateProcess " - "fallback (\"%s\")", (unsigned long) pi.dwProcessId, exeName); + "fallback (\"%s\")", (unsigned long) pid, exeName); if (pi.hThread) CloseHandle(pi.hThread); if (pi.hProcess) CloseHandle(pi.hProcess); - return true; + return pid; } static void dump_loaded_modules(const char* when) { @@ -898,13 +999,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", @@ -1300,12 +1403,23 @@ int main(int argc, char** argv) { bool launchedViaApp = false; bool launchedViaFallback = false; + // Set when LaunchApp registered the app but we take over the launch to inject the + // user's full args (see haveUserArgs). Routes to CreateProcess below. + bool reapedForUserArgs = false; + // Subset of the above where LaunchApp registered but never spawned within the wait — + // arms the post-relaunch guard against a late (slow-device) spawn. + bool relaunchedNoSpawn = false; const char* launchFailureReason = "LaunchApp path unavailable"; // User override: skip LaunchApp (it would spawn the app's configured entry, not the chosen exe) and CreateProcess the selected exe directly; the Steam session is already up. const char* directExeEnv = getenv("WN_STEAM_DIRECT_EXE"); const bool directExe = directExeEnv && directExeEnv[0] != '\0'; + // Presence flag: when set, LaunchApp only registers the app, then we reap its spawn and + // relaunch via CreateProcess with the full args (argv) — pszUserArgs crashes under Wine. + const char* userArgsEnv = getenv("WN_STEAM_USER_ARGS"); + const bool haveUserArgs = userArgsEnv && userArgsEnv[0] != '\0'; + if (directExe) { log_line("[wn-launcher] WN_STEAM_DIRECT_EXE set — user-selected exe \"%s\"; " "skipping Steam LaunchApp, launching directly via CreateProcess", @@ -1328,6 +1442,16 @@ int main(int argc, char** argv) { appMgr_vt[kVtAppMgr_LaunchApp / 8]; uint64_t gameId = (uint64_t)(appId & 0xFFFFFFu); + // Launch-option index (config.launch key) chosen on the Android side; 0 = default entry. + // Lets Steam's LaunchApp spawn the user's picked entry (e.g. MCC's -no-eac) itself. + const char* launchOptEnv = getenv("WN_STEAM_LAUNCH_OPTION"); + const uint32_t uLaunchOption = (launchOptEnv && launchOptEnv[0] != '\0') + ? (uint32_t) atoi(launchOptEnv) : 0; + if (uLaunchOption != 0) { + log_line("[wn-launcher] WN_STEAM_LAUNCH_OPTION=%u — LaunchApp will use this " + "launch-option index", uLaunchOption); + } + // RefreshAppInfo() slot — re-primes appinfo between MissingConfig retries. void* refreshAppInfoP = appMgr_vt[kVtAppMgr_RefreshAppInfo / 8]; @@ -1335,10 +1459,13 @@ int main(int argc, char** argv) { // the 35s watchdog. const int kMaxLaunchAttempts = 5; for (int attempt = 1; attempt <= kMaxLaunchAttempts && !launchedViaApp; ++attempt) { - uint64_t apiCall = launchApp(appMgr, &gameId, 0, 300, ""); + // Never pass pszUserArgs — it crashes steamclient's launch under Wine. + // With custom args we relaunch via CreateProcess after registration (below). + uint64_t apiCall = launchApp(appMgr, &gameId, uLaunchOption, 300, ""); + // prefix is a WnLauncherStatusTailer marker; relaunchForUserArgs is a bool (no creds). log_line("[wn-launcher] IClientAppManager.LaunchApp(appId=%u) " - "attempt=%d/%d -> HSteamAPICall=0x%llx", appId, - attempt, kMaxLaunchAttempts, + "attempt=%d/%d relaunchForUserArgs=%d -> HSteamAPICall=0x%llx", appId, + attempt, kMaxLaunchAttempts, haveUserArgs ? 1 : 0, (unsigned long long) apiCall); int eAppError = -1; // -1 = not polled / unknown @@ -1475,15 +1602,19 @@ int main(int argc, char** argv) { } else { // NoError(0)/indeterminate(-1): accepted. Wait WITHOUT re-dispatching // — a second LaunchApp while one is pending cancels the spawn (Wine). - const int kGameAppearLoops = 40; // 40 * 500ms = 20s + const int kGameAppearLoops = 40; // 20s + // With custom args we relaunch regardless, so cap the wait shorter; a + // slow-device late spawn is caught by the guard after the relaunch (below). + const int appearLoops = haveUserArgs ? 20 : kGameAppearLoops; // 10s vs 20s log_line("[wn-launcher] LaunchApp dispatched (attempt %d/%d, " "EAppUpdateError=%d); waiting up to %ds for \"%s\" to " "appear (committed — no re-dispatch)", attempt, kMaxLaunchAttempts, eAppError, - kGameAppearLoops / 2, exeName); - for (int w = 0; w < kGameAppearLoops && !launchedViaApp; ++w) { + appearLoops / 2, exeName); + bool appeared = false; + for (int w = 0; w < appearLoops && !appeared; ++w) { if (count_game_processes(exeName) > 0) { - launchedViaApp = true; + appeared = true; break; } if (bGetCallback && freeLastCallback) { @@ -1492,16 +1623,48 @@ int main(int argc, char** argv) { } Sleep(500); } - if (launchedViaApp) { + if (appeared && haveUserArgs) { + // LaunchApp registered + spawned the game (entry args only); reap it and + // relaunch below with the full args — same sequence the crash path relies on. + Sleep(1000); // let steamclient finish registering the spawn + if (bGetCallback && freeLastCallback) { + char cb[64]; + while (bGetCallback(pipe, cb)) freeLastCallback(pipe); + } + int killed = kill_game_processes(exeName, 0); + for (int w = 0; w < 40 && count_game_processes(exeName) != 0; ++w) { + if (bGetCallback && freeLastCallback) { + char cb[64]; + while (bGetCallback(pipe, cb)) freeLastCallback(pipe); + } + Sleep(250); // up to 10s for the reaped spawn to fully exit + } + log_line("[wn-launcher] LaunchApp registered \"%s\" (reaped %d spawn(s)); " + "relaunching with full user args (attempt %d/%d)", + exeName, killed, attempt, kMaxLaunchAttempts); + reapedForUserArgs = true; + break; + } else if (appeared) { + launchedViaApp = true; log_line("[wn-launcher] LaunchApp: \"%s\" is running " "(attempt %d/%d)", exeName, attempt, kMaxLaunchAttempts); + } else if (haveUserArgs) { + // NoError but no spawn in the shortened window — registration is committed, + // so relaunch now instead of idling; the guard below reaps a late spawn. + log_line("[wn-launcher] LaunchApp registered \"%s\" (EAppUpdateError=%d, " + "no spawn in %ds); relaunching with full user args " + "(attempt %d/%d)", exeName, eAppError, appearLoops / 2, + attempt, kMaxLaunchAttempts); + reapedForUserArgs = true; + relaunchedNoSpawn = true; + break; } else { log_line("[wn-launcher] LaunchApp attempt %d/%d: \"%s\" " "accepted (EAppUpdateError=%d) but never spawned in " "%ds — not re-dispatching (would cancel the pending " "launch) — falling back", attempt, kMaxLaunchAttempts, - exeName, eAppError, kGameAppearLoops / 2); + exeName, eAppError, appearLoops / 2); launchFailureReason = "LaunchApp accepted but the game never spawned"; break; @@ -1515,17 +1678,40 @@ int main(int argc, char** argv) { launchFailureReason = engine ? "appId was 0" : "IClientEngine was null"; } - // LaunchApp didn't bring the game up — start it directly; the "dispatched/never appeared/falling back" log markers disarm WnLauncherStatusTailer's post-dispatch watchdog. + // LaunchApp didn't bring the game up (or was reaped for a full-args relaunch) — start it + // directly; the dispatched/never-appeared/reaped log markers keep the tailer on "Launching". + DWORD relaunchPid = 0; if (!launchedViaApp) { if (directExe) { log_line("[wn-launcher] direct-exe mode: launching user-selected \"%s\" via " "CreateProcess (Steam LaunchApp skipped)", exeName); + } else if (reapedForUserArgs) { + log_line("[wn-launcher] relaunching \"%s\" with full user args via CreateProcess " + "(LaunchApp used for registration only)", exeName); } else { log_line("[wn-launcher] LaunchApp dispatched but \"%s\" never appeared " "— falling back to CreateProcess (%s)", exeName, launchFailureReason); } - launchedViaFallback = create_process_game(gameExe, exeName); + relaunchPid = create_process_game(gameExe, exeName, extraArgs); + launchedViaFallback = (relaunchPid != 0); + } + + // Slow-device guard: LaunchApp's spawn can still appear after we relaunched (late, not + // absent). Reap any game process that isn't our instance or its child, so no duplicate runs. + if (relaunchedNoSpawn && launchedViaFallback) { + for (int w = 0; w < 20; ++w) { // ~10s — covers the appear window we shortened + int dup = kill_game_processes(exeName, relaunchPid); + if (dup > 0) { + log_line("[wn-launcher] guard: reaped %d late LaunchApp spawn(s) " + "(kept our pid=%lu)", dup, (unsigned long) relaunchPid); + } + if (bGetCallback && freeLastCallback) { + char cb[64]; + while (bGetCallback(pipe, cb)) freeLastCallback(pipe); + } + Sleep(500); + } } if (launchedViaApp || launchedViaFallback) { diff --git a/app/src/main/feature/library/GameSettings.kt b/app/src/main/feature/library/GameSettings.kt index 10d8c621a..29f3a2615 100644 --- a/app/src/main/feature/library/GameSettings.kt +++ b/app/src/main/feature/library/GameSettings.kt @@ -351,6 +351,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()) @@ -3794,6 +3797,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 98d122aa4..a247a754c 100644 --- a/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt +++ b/app/src/main/feature/shortcuts/ShortcutSettingsComposeDialog.kt @@ -52,6 +52,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 @@ -364,6 +365,7 @@ class ShortcutSettingsComposeDialog private constructor( state.name.value = shortcut.getExtra("custom_name", shortcut.name).ifBlank { 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( @@ -1206,6 +1208,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..bbe4bfae1 100644 --- a/app/src/main/feature/stores/steam/data/LaunchInfo.kt +++ b/app/src/main/feature/stores/steam/data/LaunchInfo.kt @@ -11,6 +11,11 @@ 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 = "", + // config.launch VDF key ("0","1",…) — Steam's launch-option id, which can differ from the + // list position when keys are non-contiguous. -1 (pre-launchId cache) = fall back to position. + val launchId: Int = -1, @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 51d9325ac..4c0f9283e 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 @@ -1477,6 +1478,70 @@ class SteamService : Service() { fun getInstalledExe(appId: Int): String = getWindowsLaunchInfos(appId).firstOrNull()?.executable ?: "" + /** + * `config.launch` key of the entry matching (executable, arguments), handed to + * `IClientAppManager::LaunchApp` (position fallback for pre-launchId caches; -1 if none). + */ + fun launchOptionIndexFor( + appId: Int, + executable: String, + arguments: String, + ): Int { + val launches = getAppInfoOf(appId)?.config?.launch ?: return -1 + val exe = normalizeRelativeExe(executable) + val args = arguments.trim() + val i = + launches.indexOfFirst { + normalizeRelativeExe(it.executable).equals(exe, ignoreCase = true) && + it.arguments.trim() == args + } + return launchOptionKeyAt(launches, i) + } + + /** + * `config.launch` key of the first entry whose executable matches — exe-only fallback when + * the picker field was cleared (position fallback for pre-launchId caches; -1 if none). + */ + fun launchOptionIndexForExe( + appId: Int, + executable: String, + ): Int { + val launches = getAppInfoOf(appId)?.config?.launch ?: return -1 + val exe = normalizeRelativeExe(executable) + val i = + launches.indexOfFirst { + normalizeRelativeExe(it.executable).equals(exe, ignoreCase = true) + } + return launchOptionKeyAt(launches, i) + } + + private fun launchOptionKeyAt( + launches: List, + index: Int, + ): Int = + if (index < 0) { + -1 + } else { + launches[index].launchId.takeIf { it >= 0 } ?: index + } + + /** + * Arguments of the `config.launch` entry [launchKey] identifies — the args Steam itself + * would pass, so a relaunch can carry them too. Empty when the entry is unknown. + */ + fun launchOptionArgsForKey( + appId: Int, + launchKey: Int, + ): String { + if (launchKey < 0) return "" + val launches = getAppInfoOf(appId)?.config?.launch ?: return "" + val entry = + launches.firstOrNull { it.launchId == launchKey } + // Pre-launchId caches key by position; only trust that when no ids are present. + ?: launches.getOrNull(launchKey)?.takeIf { it.launchId < 0 } + return entry?.arguments.orEmpty() + } + fun getLaunchExecutable( appId: String, container: Container, @@ -1485,6 +1550,132 @@ class SteamService : Service() { return container.executablePath.ifEmpty { getInstalledExe(gameId) } } + private data class SteamShortcutHit( + val extras: Map, + val container: Container, + val desktopFile: File, + ) + + /** + * Canonical shortcut for a Steam game: first match over ContainerManager's loaded + * containers, so the pick side and the launch side always resolve the same file — + * a raw home-dir scan also saw orphan/corrupt containers the launch never loads. + * Reads `[Extra Data]` straight from the .desktop; Shortcut construction decodes + * bitmaps, too heavy for read paths. + */ + private fun readSteamShortcutExtras( + context: Context, + appId: Int, + manager: ContainerManager = ContainerManager(context), + ): SteamShortcutHit? { + val appIdStr = appId.toString() + for (container in manager.containers) { + val files = container.desktopDir.listFiles { f -> f.name.endsWith(".desktop") } ?: continue + for (file in files.sortedBy { it.name.lowercase() }) { + // 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, container, file) + } + } + } + return null + } + + /** Constructs just the canonical shortcut — loadShortcuts() would decode every shortcut's icon. */ + fun locateSteamShortcut( + context: Context, + appId: Int, + manager: ContainerManager = ContainerManager(context), + ): Shortcut? { + val hit = readSteamShortcutExtras(context, appId, manager) ?: return null + return Shortcut(hit.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; 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() + // No container write here: Container.saveData() would clobber dxwrapperConfig (DXVK/VKD3D). + 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 + } + + /** True when [relativePath] is a file under [baseDirPath], matching segments case-insensitively. */ + fun fileExistsIgnoreCase( + baseDirPath: String, + relativePath: String, + ): Boolean = resolvePathCaseInsensitive(baseDirPath, relativePath)?.isFile == true + + /** 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 + } + suspend fun deleteApp(appId: Int): Boolean = withContext(Dispatchers.IO) { val appDirPath = getAppDirPath(appId) @@ -1831,7 +2022,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() diff --git a/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt b/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt index bdb6d3d23..4fbd841b6 100644 --- a/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt +++ b/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt @@ -195,10 +195,12 @@ fun WnKeyValue.generateSteamApp(): SteamApp = launch = this["config"]["launch"].children.map { LaunchInfo( + launchId = it.name?.trim()?.toIntOrNull() ?: -1, executable = it["executable"].value?.replace('\\', '/').orEmpty(), 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 40c66427b..473100ed4 100644 --- a/app/src/main/feature/stores/steam/utils/SteamUtils.kt +++ b/app/src/main/feature/stores/steam/utils/SteamUtils.kt @@ -1491,8 +1491,8 @@ object SteamUtils { } /** - * Updates localconfig.vdf with the container's LaunchOptions for the given appId, - * and updates UserConfig/MountedConfig language in the ACF manifest. + * Clears any stale localconfig.vdf LaunchOptions for the given appId (args reach the game + * via argv, never Steam), and updates UserConfig/MountedConfig language in the ACF manifest. */ /** * Disables Steam Cloud sync for a single app entry inside the parsed localconfig.vdf. @@ -1522,39 +1522,46 @@ 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, + // Custom-only launch args for the Rust client channel; the picked entry's own args reach + // the game via the launch-option index. + 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") @@ -1563,6 +1570,9 @@ object SteamUtils { val localConfigFile = File(configPath, "localconfig.vdf") + // LaunchOptions is only ever cleared, never set: in-Wine Steam runs a non-empty value + // through ShellExecute, which pops a modal "File not found." box that wedges the + // LaunchApp job. Custom args reach the game via argv (reap + CreateProcess) instead. if (localConfigFile.exists()) { val vdfContent = FileUtils.readFileAsString(localConfigFile.absolutePath) if (vdfContent != null) { @@ -1570,18 +1580,26 @@ object SteamUtils { if (vdfData != null) { val app = vdfData["Software"]["Valve"]["Steam"]["apps"][appId] val option = app.children.firstOrNull { it.name == "LaunchOptions" } - if (option != null) { - option.value = exeCommandLine.orEmpty() - } else { - app.children.add(KeyValue("LaunchOptions", exeCommandLine)) + val cloud = app.children.firstOrNull { it.name == "cloud" } + // Rewriting the file is itself disruptive to a launch, so skip a no-op pass. + val alreadyClean = + option?.value.isNullOrEmpty() && + app.children.firstOrNull { it.name == "cloudenabled" }?.value == "0" && + cloud?.children?.firstOrNull { it.name == "last_sync_state" }?.value == "synchronized" + if (!alreadyClean) { + if (option != null) { + option.value = "" + } else { + app.children.add(KeyValue("LaunchOptions", "")) + } + disableSteamCloudForApp(app) + vdfData.saveToFile(localConfigFile, false) } - disableSteamCloudForApp(app) - vdfData.saveToFile(localConfigFile, false) } } } else { val vdfData = KeyValue("UserLocalConfigStore") - val option = KeyValue("LaunchOptions", exeCommandLine) + val option = KeyValue("LaunchOptions", "") val software = KeyValue("Software") val valve = KeyValue("Valve") val steam = KeyValue("Steam") diff --git a/app/src/main/feature/stores/steam/wnsteam/WnLauncherStatusTailer.kt b/app/src/main/feature/stores/steam/wnsteam/WnLauncherStatusTailer.kt index 201f3d96f..51b523e0d 100644 --- a/app/src/main/feature/stores/steam/wnsteam/WnLauncherStatusTailer.kt +++ b/app/src/main/feature/stores/steam/wnsteam/WnLauncherStatusTailer.kt @@ -118,6 +118,10 @@ class WnLauncherStatusTailer( val isCreateProcessFallback = line.contains("LaunchApp dispatched") && line.contains("never appeared") && line.contains("falling back to CreateProcess") + // Registration-only path: LaunchApp registered + spawned the game, the launcher + // reaped that spawn and is relaunching with the user's full args via CreateProcess. + val isRegistrationReap = line.contains("LaunchApp registered") + && line.contains("relaunching with full user args") val phase = phaseFor(line) if (phase != null && phase != lastEmitted) { emitPhase(phase, line) @@ -135,9 +139,10 @@ class WnLauncherStatusTailer( } else if (isFatal) { android.util.Log.w(TAG, "fatal phase (launcher LoadLibrary failed) — signaling launch failure") main.post { onLaunchFailed?.invoke(appContext.getString(R.string.preloader_steam_launcher_start_failed)) } - } else if (isCreateProcessFallback) { - // Keep the UI on "Launching " through the fallback; disarm the watchdog. - android.util.Log.w(TAG, "LaunchApp exhausted retries — launcher will try CreateProcess fallback (UI stays on Launching)") + } else if (isCreateProcessFallback || isRegistrationReap) { + // Keep the UI on "Launching " through the fallback / registration reap; + // disarm the watchdog so the reap + relaunch window can't trip a spurious failure. + android.util.Log.w(TAG, "LaunchApp handing off to CreateProcess (UI stays on Launching)") launchAppDispatchedAt = 0L } } diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index 50f49e3cb..b95eb9bee 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -115,6 +115,10 @@ Error al buscar 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-da/strings.xml b/app/src/main/res/values-da/strings.xml index 74220fdf5..93a29ef76 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 88e6265be..448606147 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 fa63c1ab0..95a010ac3 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-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 07c9a5a80..5ce5a1e64 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -115,6 +115,10 @@ Päivitysten tarkistus epäonnistui Päivitä Workshop + Käynnistysasetukset + Käynnistysasetuksen tallentaminen epäonnistui + %1$d käynnistysasetusta + Käynnistysasetuksen argumentit: %1$s Tarkista tiedostot Tarkistetaan %1$s — katso Lataukset-välilehteä Steam-asetukset diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 7c206f6f9..03fff1518 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 5b79aef1c..aafdd0dd2 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 2edbb522e..14cfe9fb0 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-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index d35a7911c..cc28993ed 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -115,6 +115,10 @@ 更新の確認に失敗しました 更新 ワークショップ + 起動オプション + 起動オプションを保存できませんでした + %1$d件の起動オプション + 起動オプションの引数: %1$s ファイルを検証 %1$s を検証中 — ダウンロードタブを確認してください Steamオプション diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 90924c822..0ccfb532a 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-no/strings.xml b/app/src/main/res/values-no/strings.xml index 4322f1f71..2c8d4a233 100644 --- a/app/src/main/res/values-no/strings.xml +++ b/app/src/main/res/values-no/strings.xml @@ -115,6 +115,10 @@ Oppdateringssjekk mislyktes Oppdater Workshop + Oppstartsalternativer + Kunne ikke lagre oppstartsalternativet + %1$d oppstartsalternativer + Argumenter for oppstartsalternativ: %1$s Bekreft filer Bekrefter %1$s — sjekk Nedlastinger-fanen Steam-alternativer diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index a96c650f0..b43e23642 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 dd5e3299b..f3d202adc 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-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 8aee136c3..f241cbb04 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -115,6 +115,10 @@ Falha na verificação de atualização Atualizar Workshop + Opções de inicialização + Não foi possível guardar a opção de inicialização + %1$d opções de inicialização + Argumentos da opção de inicialização: %1$s Verificar Ficheiros A verificar %1$s — consulte o separador Transferências 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 289aed247..a2bba0eaf 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 32f7845a5..b5eb58906 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-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 14bb664ae..730f4f148 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -115,6 +115,10 @@ Uppdateringskontroll misslyckades Uppdatera Workshop + Startalternativ + Det gick inte att spara startalternativet + %1$d startalternativ + Argument för startalternativ: %1$s Verifiera filer Verifierar %1$s — kontrollera fliken Nedladdningar Steam-alternativ diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index 2fd054da3..660b058db 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -115,6 +115,10 @@ ตรวจสอบการอัปเดตไม่สำเร็จ อัปเดต Workshop + ตัวเลือกการเปิดเกม + ไม่สามารถบันทึกตัวเลือกการเปิดเกมได้ + ตัวเลือกการเปิดเกม %1$d รายการ + อาร์กิวเมนต์ของตัวเลือกการเปิดเกม: %1$s ตรวจสอบไฟล์ กำลังตรวจสอบ %1$s — ดูที่แท็บดาวน์โหลด ตัวเลือก Steam diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 1c28b088e..7ca497fc4 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -115,6 +115,10 @@ Güncelleme denetimi başarısız Güncelle Workshop + Başlatma Seçenekleri + Başlatma seçeneği kaydedilemedi + %1$d başlatma seçeneği + Başlatma seçeneği argümanları: %1$s Dosyaları Doğrula %1$s doğrulanıyor — İndirilenler sekmesini denetleyin Steam seçenekleri diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 459f734e8..b7da6e362 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 eabb855a3..4a13bde35 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 b5157bd88..d07487fd3 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 c1b296586..8eb8c84c3 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/compat/SteamBridge.java b/app/src/main/runtime/compat/SteamBridge.java index fbaa65bc0..4b8ba817e 100644 --- a/app/src/main/runtime/compat/SteamBridge.java +++ b/app/src/main/runtime/compat/SteamBridge.java @@ -43,6 +43,59 @@ public static String getInstalledExe(int appId) { } } + /** config.launch index of the selected option (Steam LaunchApp launch-option key), or -1. */ + public static int launchOptionIndexFor(int appId, String executable, String arguments) { + try { + Class companion = + Class.forName("com.winlator.cmod.feature.stores.steam.service.SteamService$Companion"); + Object instance = + Class.forName("com.winlator.cmod.feature.stores.steam.service.SteamService") + .getField("Companion") + .get(null); + Method method = + companion.getMethod("launchOptionIndexFor", int.class, String.class, String.class); + return (Integer) method.invoke(instance, appId, executable, arguments); + } catch (Exception e) { + Log.e(TAG, "Failed to call SteamService.launchOptionIndexFor", e); + return -1; + } + } + + /** First config.launch index whose executable matches (exe-only fallback), or -1. */ + public static int launchOptionIndexForExe(int appId, String executable) { + try { + Class companion = + Class.forName("com.winlator.cmod.feature.stores.steam.service.SteamService$Companion"); + Object instance = + Class.forName("com.winlator.cmod.feature.stores.steam.service.SteamService") + .getField("Companion") + .get(null); + Method method = companion.getMethod("launchOptionIndexForExe", int.class, String.class); + return (Integer) method.invoke(instance, appId, executable); + } catch (Exception e) { + Log.e(TAG, "Failed to call SteamService.launchOptionIndexForExe", e); + return -1; + } + } + + /** Args of the config.launch entry with this key, or "" when appinfo can't resolve it. */ + public static String launchOptionArgsForKey(int appId, int launchKey) { + try { + Class companion = + Class.forName("com.winlator.cmod.feature.stores.steam.service.SteamService$Companion"); + Object instance = + Class.forName("com.winlator.cmod.feature.stores.steam.service.SteamService") + .getField("Companion") + .get(null); + Method method = companion.getMethod("launchOptionArgsForKey", int.class, int.class); + Object result = method.invoke(instance, appId, launchKey); + return result != null ? (String) result : ""; + } catch (Exception e) { + Log.e(TAG, "Failed to call SteamService.launchOptionArgsForKey", e); + return ""; + } + } + /** Downloads experimental-drm if missing, then extracts it. Blocking call. */ public static boolean ensureColdClientSupportReady(Context context) { try { 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 865a99950..603d20818 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -51,6 +51,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; @@ -296,6 +297,14 @@ public class XServerDisplayActivity extends FixedFontScaleAppCompatActivity { private final EnvVars envVars = new EnvVars(); // True when the chosen launch exe differs from Steam's configured entry: launcher skips Steam LaunchApp and CreateProcess'es the selected exe directly. Recomputed per launch. private boolean wnSteamDirectExeOverride = false; + // config.launch index to launch via Steam's LaunchApp (>=0), else -1 for direct-exe. Per launch. + private int wnSteamLaunchOptionIndex = -1; + // Presence flag (custom-only game args): when non-empty the launcher uses LaunchApp only to + // register the app, then reaps that spawn and relaunches with the full args via CreateProcess. Per launch. + private String wnSteamUserArgs = ""; + // config.launch args of the entry being launched, resolved from appinfo per launch; + // null falls back to the persisted override. Steam applies these only on its own spawn. + private String wnSteamEntryArgs = null; private boolean firstTimeBoot = false; private SharedPreferences preferences; private boolean isMouseDisabled = false; @@ -2109,17 +2118,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) { @@ -6898,6 +6900,24 @@ private void setupXEnvironment() throws PackageManager.NameNotFoundException { "Steam Launcher: WN_STEAM_DIRECT_EXE=1 — user-overridden " + "launch exe; launcher will CreateProcess the selected exe " + "directly (Steam LaunchApp skipped)"); + } else if (wnSteamLaunchOptionIndex >= 0) { + envVars.put("WN_STEAM_LAUNCH_OPTION", + String.valueOf(wnSteamLaunchOptionIndex)); + Log.i("XServerDisplayActivity", + "Steam Launcher: WN_STEAM_LAUNCH_OPTION=" + + wnSteamLaunchOptionIndex + " — launcher will use Steam LaunchApp " + + "with the selected launch-option index"); + } + // Presence flag: the launcher reaps the LaunchApp spawn and relaunches via + // CreateProcess with the full args (argv). Direct-exe already gets them via argv. + if (!wnSteamDirectExeOverride + && wnSteamUserArgs != null && !wnSteamUserArgs.isEmpty()) { + envVars.put("WN_STEAM_USER_ARGS", wnSteamUserArgs); + // Length only: execArgs can carry +password style credentials. + Log.i("XServerDisplayActivity", + "Steam Launcher: WN_STEAM_USER_ARGS len=" + + wnSteamUserArgs.length() + + " — launcher will reap the LaunchApp spawn and relaunch with full args"); } File planWCa = new File(container.getRootDir(), ".wine/drive_c/Program Files (x86)/Steam/wnsteam_cacert.pem"); @@ -8342,7 +8362,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 = appendSteamJoinConnect( + wnSteamLaunchOptionIndex = -1; + wnSteamEntryArgs = null; + String steamExtraArgs = steamCombinedGameArgs(); + wnSteamUserArgs = appendSteamJoinConnect( com.winlator.cmod.feature.stores.steam.utils.SteamLaunchOptions .gameArgs(shortcut.getSettingExtra("execArgs", container.getExecArgs()))); steamExtraArgs = (steamExtraArgs != null && !steamExtraArgs.isEmpty()) ? " " + steamExtraArgs : ""; @@ -8394,8 +8417,33 @@ 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); + boolean exeOverridden = isUserOverriddenSteamExe(appId, relativeExe); + String launchExeArgs = shortcut.getExtra("launch_exe_args"); + // Route through Steam's LaunchApp with the config.launch index of the exe that will + // run (registers the game, survives relaunch); direct-exe only for exes not in appinfo. + String pickedExe = shortcut.getExtra("launch_option_exe"); + String pickedArgs = shortcut.getExtra("launch_option_args"); + int optIndex = -1; + if (pickedExe != null && !pickedExe.isEmpty()) { + optIndex = SteamBridge.launchOptionIndexFor(appId, pickedExe, + pickedArgs != null ? pickedArgs : ""); + } + if (optIndex < 0 && !relativeExe.isEmpty()) { + optIndex = SteamBridge.launchOptionIndexForExe(appId, relativeExe); + } + wnSteamLaunchOptionIndex = optIndex; + boolean useLaunchOptionIndex = optIndex >= 0; + wnSteamDirectExeOverride = !useLaunchOptionIndex + && (exeOverridden || !launchExeArgs.isEmpty()); + + if (useLaunchOptionIndex) { + // The entry's own args live in appinfo and ride only Steam's own spawn, so a + // reap-relaunch or fallback would drop them (a default-entry pick persists no + // override). Resolve them here and rebuild the command line that argv carries. + wnSteamEntryArgs = SteamBridge.launchOptionArgsForKey(appId, optIndex); + steamExtraArgs = steamCombinedGameArgs(); + steamExtraArgs = !steamExtraArgs.isEmpty() ? " " + steamExtraArgs : ""; + } if (!relativeExe.isEmpty() && !gameDirName.isEmpty()) { String steamGameExe = "C:\\Program Files (x86)\\Steam\\steamapps\\common\\" @@ -8728,8 +8776,7 @@ private void writeColdClientIniDirect(int appId, String gameDirName, String rela exeRunDir = exePath.substring(0, lastSep); } - 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 = steamCombinedGameArgs(); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); @@ -8814,7 +8861,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); @@ -8918,12 +8965,42 @@ 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); } + /** + * Launch entry's args (resolved from appinfo, else the persisted {@code launch_exe_args} + * override) merged with the given custom args — the single resolver for every Steam channel. + */ + private String combineWithSelectedLaunchArgs(String customArgs) { + String entryArgs = ""; + if (wnSteamEntryArgs != null) { + entryArgs = wnSteamEntryArgs; + } else if (shortcut != null) { + // Appinfo couldn't name the entry: fall back to the picker's own record, since the + // launch_exe_args override is empty for a default-entry pick and would drop its args. + entryArgs = !shortcut.getExtra("launch_option_exe").isEmpty() + ? shortcut.getExtra("launch_option_args") + : shortcut.getExtra("launch_exe_args"); + } + return com.winlator.cmod.feature.stores.steam.utils.SteamLaunchOptions.combineSteamLaunchArgs( + entryArgs, customArgs); + } + + /** Combined (picked-entry + custom) game args — the string every args-on-cmdline Steam channel ships. */ + private String steamCombinedGameArgs() { + String perGameExecArgs = shortcut != null + ? shortcut.getSettingExtra("execArgs", container.getExecArgs()) + : container.getExecArgs(); + return appendSteamJoinConnect( + com.winlator.cmod.feature.stores.steam.utils.SteamLaunchOptions + .gameArgs(combineWithSelectedLaunchArgs(perGameExecArgs))); + } + private String resolveRelativeGameExe(int appId, String gameInstPath) { // Per-game launch_exe_path wins over the shared container cache. String shortcutExePath = resolveShortcutSteamExecutablePath(gameInstPath); @@ -10530,14 +10607,29 @@ 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; + // Combined (entry + custom) line, used only as the stamp key so a changed selection or + // custom-arg edit re-runs the warm/cold steam-env setup below. + String effectiveLaunchOptions = combineWithSelectedLaunchArgs( + shortcut != null ? shortcut.getSettingExtra("execArgs", container.getExecArgs()) + : container.getExecArgs()); + // Rust setLaunchCommandLine gets custom-only args: the entry's own args come via the + // launch-option index, so the combined line would double them. + String customLaunchOptions = (shortcut != null + ? shortcut.getSettingExtra("execArgs", container.getExecArgs()) + : container.getExecArgs()); + if (customLaunchOptions == null) customLaunchOptions = ""; + customLaunchOptions = customLaunchOptions.trim(); + String expectedStamp = "v2|" + appId + "|" + steamUserDataId + + "|" + effectiveLaunchOptions; String existingStamp = steamEnvStamp.exists() ? FileUtils.readString(steamEnvStamp).trim() : ""; boolean steamEnvWarm = expectedStamp.equals(existingStamp); + // These feed steamclient's LaunchApp spawn: used directly on the no-reap (picker-only) path; + // on the custom-args path the launcher reaps that spawn and CreateProcess delivers the args. if (!steamEnvWarm) { try { SteamUtils.autoLoginUserChanges(imageFs); @@ -10548,7 +10640,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, + customLaunchOptions); setupLightweightSteamConfig(steamDir, steamUserDataId); try { @@ -10558,6 +10651,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(customLaunchOptions); + SteamUtils.pushAppFamilyShared(String.valueOf(appId)); Log.d("XServerDisplayActivity", "Steam env warm-cache hit (appId=" + appId + ", userId=" + steamUserDataId + ") — skipping reconcile + autoLogin");