diff --git a/app/src/main/app/shell/BetaBranchScreen.kt b/app/src/main/app/shell/BetaBranchScreen.kt new file mode 100644 index 000000000..82ce226c5 --- /dev/null +++ b/app/src/main/app/shell/BetaBranchScreen.kt @@ -0,0 +1,271 @@ +package com.winlator.cmod.app.shell + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AltRoute +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.Lock +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.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.winlator.cmod.R +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** A single Steam beta branch entry from appinfo depots.branches. */ +internal data class StoreBetaBranchItem( + val name: String, + val buildId: Long, + val timeUpdated: Date?, + val pwdRequired: Boolean, +) + +// Palette — mirrors the LaunchOptions / Workshop window so the modal feels native. +private val BbBg = Color(0xFF12121B) +private val BbBorder = Color(0xFF2A2A3A) +private val BbAccent = Color(0xFF1A9FFF) +private val BbAccentGlow = Color(0xFF58A6FF) +private val BbTextPrimary = Color(0xFFF0F4FF) +private val BbTextSecondary = Color(0xFF93A6BC) +private val BbScrim = Color(0xFF000000) +private val BbLocked = Color(0xFF505060) + +/** + * Steam beta-branch picker — a Workshop-shaped modal window listing the game's + * PICS depots.branches entries. Tapping an unlocked row persists the selection, + * closes the window and triggers the update flow so the branch's build actually + * downloads. Password-protected branches are shown as disabled (no + * beta-password support in this app). + * + * Stateless: data and callbacks are hoisted to the BetaBranchesDialog wrapper. + */ +@Composable +internal fun StoreBetaBranchScreen( + gameTitle: String, + branches: List, + selectedBranch: StoreBetaBranchItem?, + onSelect: (StoreBetaBranchItem) -> Unit, + onClose: () -> Unit, +) { + BoxWithConstraints( + modifier = + Modifier + .fillMaxSize() + .background(BbScrim.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 = BbBg, + border = BorderStroke(1.dp, BbBorder), + tonalElevation = 8.dp, + ) { + Column(Modifier.fillMaxWidth()) { + BetaBranchHeader( + gameTitle = gameTitle, + branchCount = branches.size, + onClose = onClose, + ) + HorizontalDivider(color = BbBorder, thickness = 0.5.dp) + LazyColumn( + modifier = Modifier.fillMaxWidth().weight(1f, fill = false), + contentPadding = PaddingValues(vertical = 4.dp), + ) { + itemsIndexed(branches) { index, branch -> + BetaBranchPickerRow( + branch = branch, + selected = branch == selectedBranch, + onClick = { onSelect(branch) }, + ) + if (index < branches.lastIndex) { + HorizontalDivider( + color = Color.White.copy(alpha = 0.06f), + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 14.dp), + ) + } + } + } + } + } + } +} + +@Composable +private fun BetaBranchHeader( + gameTitle: String, + branchCount: 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(BbAccent.copy(alpha = 0.16f)), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Outlined.AltRoute, + contentDescription = null, + tint = BbAccentGlow, + modifier = Modifier.size(19.dp), + ) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + stringResource(R.string.store_game_beta_branch).uppercase(), + color = BbTextSecondary, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.9.sp, + ) + Text( + gameTitle, + style = MaterialTheme.typography.titleSmall, + color = BbTextPrimary, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Surface( + modifier = + Modifier.semantics { + contentDescription = "$branchCount branches" + }, + color = BbAccent.copy(alpha = 0.14f), + shape = RoundedCornerShape(7.dp), + ) { + Text( + branchCount.toString(), + color = BbAccentGlow, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 9.dp, vertical = 3.dp), + ) + } + IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) { + Icon( + Icons.Outlined.Close, + contentDescription = "Close", + tint = BbTextSecondary, + modifier = Modifier.size(20.dp), + ) + } + } +} + +@Composable +private fun BetaBranchPickerRow( + branch: StoreBetaBranchItem, + selected: Boolean, + onClick: () -> Unit, +) { + val rowAlpha = if (branch.pwdRequired) 0.45f else 1f + Row( + modifier = + Modifier + .fillMaxWidth() + .then(if (!branch.pwdRequired) Modifier.clickable(onClick = onClick) else Modifier) + .alpha(rowAlpha) + .padding(horizontal = 14.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(11.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + val displayName = + if (branch.name.equals("public", ignoreCase = true)) { + "${branch.name} (default)" + } else { + branch.name + } + Text( + displayName, + color = if (selected) BbAccentGlow else BbTextPrimary, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val dateStr = + remember(branch.timeUpdated) { + branch.timeUpdated + ?.let { SimpleDateFormat("MMM d, yyyy", Locale.US).format(it) } + ?: "—" + } + Text( + "build ${branch.buildId} · $dateStr", + color = BbTextSecondary, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + when { + branch.pwdRequired -> Icon( + Icons.Outlined.Lock, + contentDescription = null, + tint = BbLocked, + modifier = Modifier.size(17.dp), + ) + selected -> Icon( + Icons.Outlined.Check, + contentDescription = null, + tint = BbAccentGlow, + modifier = Modifier.size(18.dp), + ) + } + } +} diff --git a/app/src/main/app/shell/LaunchOptionsScreen.kt b/app/src/main/app/shell/LaunchOptionsScreen.kt new file mode 100644 index 000000000..8309a381b --- /dev/null +++ b/app/src/main/app/shell/LaunchOptionsScreen.kt @@ -0,0 +1,251 @@ +package com.winlator.cmod.app.shell + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.RocketLaunch +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.winlator.cmod.R + +/** A single Steam launch option (appinfo `config.launch` entry). */ +internal data class StoreLaunchOptionItem( + // Relative path, '/'-separated. + val executable: String, + val arguments: String, + val label: String, +) + +// Palette — mirrors the Workshop window so the modal feels native. +private val LoBg = Color(0xFF12121B) +private val LoBorder = Color(0xFF2A2A3A) +private val LoAccent = Color(0xFF1A9FFF) +private val LoAccentGlow = Color(0xFF58A6FF) +private val LoTextPrimary = Color(0xFFF0F4FF) +private val LoTextSecondary = Color(0xFF93A6BC) +private val LoScrim = Color(0xFF000000) + +/** + * Steam launch-option picker — a Workshop-shaped modal window listing the + * game's appinfo `config.launch` entries. Tapping a row persists it as the + * game's default; the check mark moves to confirm and the window stays open. + * + * Stateless: data and callbacks are hoisted to the LaunchOptionsDialog wrapper. + */ +@Composable +internal fun StoreLaunchOptionsScreen( + gameTitle: String, + options: List, + selectedOption: StoreLaunchOptionItem?, + onSelect: (StoreLaunchOptionItem) -> Unit, + onClose: () -> Unit, +) { + BoxWithConstraints( + modifier = + Modifier + .fillMaxSize() + // Dim the game-detail screen behind so the modal reads as foreground. + .background(LoScrim.copy(alpha = 0.6f)) + .windowInsetsPadding(WindowInsets.navigationBars), + contentAlignment = Alignment.Center, + ) { + val dialogWidth = (maxWidth - 32.dp).coerceAtMost(560.dp) + val dialogMaxHeight = (maxHeight - 48.dp).coerceIn(220.dp, 640.dp) + Surface( + modifier = + Modifier + .widthIn(min = 320.dp, max = dialogWidth) + .fillMaxWidth() + .heightIn(max = dialogMaxHeight), + shape = RoundedCornerShape(14.dp), + color = LoBg, + border = BorderStroke(1.dp, LoBorder), + tonalElevation = 8.dp, + ) { + Column(Modifier.fillMaxWidth()) { + LaunchOptionsHeader( + gameTitle = gameTitle, + optionCount = options.size, + onClose = onClose, + ) + HorizontalDivider(color = LoBorder, thickness = 0.5.dp) + LazyColumn( + // fill = false: the window wraps short lists instead of + // stretching to the max height. + modifier = Modifier.fillMaxWidth().weight(1f, fill = false), + contentPadding = PaddingValues(vertical = 4.dp), + ) { + itemsIndexed(options) { index, option -> + LaunchOptionPickerRow( + option = option, + selected = option == selectedOption, + onClick = { onSelect(option) }, + ) + if (index < options.lastIndex) { + HorizontalDivider( + color = Color.White.copy(alpha = 0.06f), + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 14.dp), + ) + } + } + } + } + } + } +} + +@Composable +private fun LaunchOptionsHeader( + gameTitle: String, + optionCount: Int, + onClose: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + Modifier + .size(34.dp) + .clip(RoundedCornerShape(9.dp)) + .background(LoAccent.copy(alpha = 0.16f)), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Outlined.RocketLaunch, + contentDescription = null, + tint = LoAccentGlow, + modifier = Modifier.size(19.dp), + ) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + stringResource(R.string.store_game_launch_options).uppercase(), + color = LoTextSecondary, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.9.sp, + ) + Text( + gameTitle, + style = MaterialTheme.typography.titleSmall, + color = LoTextPrimary, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Surface( + modifier = + Modifier.semantics { + contentDescription = "$optionCount launch options" + }, + color = LoAccent.copy(alpha = 0.14f), + shape = RoundedCornerShape(7.dp), + ) { + Text( + optionCount.toString(), + color = LoAccentGlow, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 9.dp, vertical = 3.dp), + ) + } + IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) { + Icon( + Icons.Outlined.Close, + contentDescription = "Close", + tint = LoTextSecondary, + modifier = Modifier.size(20.dp), + ) + } + } +} + +@Composable +private fun LaunchOptionPickerRow( + option: StoreLaunchOptionItem, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(11.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + option.label, + color = if (selected) LoAccentGlow else LoTextPrimary, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + buildString { + append(option.executable) + if (option.arguments.isNotBlank()) { + append(" · ") + append(option.arguments) + } + }, + color = LoTextSecondary, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (selected) { + Icon( + Icons.Outlined.Check, + contentDescription = null, + tint = LoAccentGlow, + modifier = Modifier.size(18.dp), + ) + } + } +} diff --git a/app/src/main/app/shell/LibraryGameLaunchScreen.kt b/app/src/main/app/shell/LibraryGameLaunchScreen.kt index 9f1bac0b2..a8ccf426b 100644 --- a/app/src/main/app/shell/LibraryGameLaunchScreen.kt +++ b/app/src/main/app/shell/LibraryGameLaunchScreen.kt @@ -49,6 +49,8 @@ import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.DesktopWindows import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.AltRoute +import androidx.compose.material.icons.outlined.RocketLaunch import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.Save @@ -133,6 +135,9 @@ internal fun LibraryGameLaunchScreen( showVerifyFiles: Boolean = true, showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, + // Nullable on purpose: null hides the menu item. + onLaunchOptions: (() -> Unit)? = null, + onBetaBranches: (() -> Unit)? = null, playEnabled: Boolean = true, playDisabledLabel: String? = null, onBack: () -> Unit, @@ -272,6 +277,8 @@ internal fun LibraryGameLaunchScreen( onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, + onLaunchOptions = onLaunchOptions, + onBetaBranches = onBetaBranches, ) } @@ -807,6 +814,9 @@ private fun SourceTag( onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, + // Nullable on purpose: null hides the menu item. + onLaunchOptions: (() -> Unit)? = null, + onBetaBranches: (() -> Unit)? = null, ) { var menuOpen by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableStateOf(0) } @@ -877,6 +887,20 @@ private fun SourceTag( enabled = areSteamActionsEnabled, ) { menuOpen = false; onWorkshop() } } + if (onLaunchOptions != null) { + LaunchSourceMenuItem( + icon = Icons.Outlined.RocketLaunch, + label = stringResource(R.string.store_game_launch_options), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onLaunchOptions() } + } + if (onBetaBranches != null) { + LaunchSourceMenuItem( + icon = Icons.Outlined.AltRoute, + label = stringResource(R.string.store_game_beta_branch), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onBetaBranches() } + } } } } diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt index 64b4fbd8e..8836970da 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -57,6 +57,8 @@ 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.AltRoute +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 @@ -121,6 +123,59 @@ private val StoreTextPrimary = Color(0xFFF0F4FF) private val StoreTextSecondary = Color(0xFF93A6BC) private val StoreDanger = Color(0xFFFF6B6B) +/** + * All StoreGameDetailScreen inputs in one holder. The split exists for ART's + * bytecode verifier: with the 40+-param signature and the screen's body in a + * single method, the frame needed 260+ registers and D8's prologue emitted + * copies this device class rejects (VerifyError: "copy-cat1 v0<-v260" / + * "wide register has type Low-half"). The wrapper below keeps the public + * defaulted signature in a tiny frame; the body lives in a one-param method. + */ +internal class StoreGameDetailParams( + val title: String, + val subtitle: String, + val sourceLabel: String, + val heroImageUrl: Any?, + val isLoading: Boolean, + val isInstalled: Boolean, + val installPathDisplay: String, + val downloadSize: Long, + val installSize: Long, + val availableBytes: Long, + val isInstallEnabled: Boolean, + val isDownloadActionEnabled: Boolean, + val customPathLabel: String, + val showCustomPath: Boolean, + val showCloudSync: Boolean, + val showUninstall: Boolean, + val showUpdateCheck: Boolean, + val isCheckingForUpdate: Boolean, + val isUpdateAvailable: Boolean, + val updateDownloadSize: Long, + val updateStatusText: String?, + val isUpdateActionEnabled: Boolean, + val isUpdateCheckCoolingDown: Boolean, + val showWorkshop: Boolean, + val showVerifyFiles: Boolean, + val areSteamActionsEnabled: Boolean, + val onLaunchOptions: (() -> Unit)?, + val onBetaBranches: (() -> Unit)?, + val dlcs: List, + val selectedDlcIds: Set, + val isDlcSelectionEnabled: Boolean, + val onBack: () -> Unit, + val onInstall: () -> Unit, + val onCheckForUpdate: () -> Unit, + val onWorkshop: () -> Unit, + val onVerifyFiles: () -> Unit, + val onDownloadUpdate: () -> Unit, + val onUninstall: () -> Unit, + val onCloudSync: () -> Unit, + val onCustomPath: () -> Unit, + val onToggleDlc: (Int) -> Unit, + val onToggleSelectAllDlcs: () -> Unit, +) + @Composable internal fun StoreGameDetailScreen( title: String, @@ -149,6 +204,9 @@ internal fun StoreGameDetailScreen( showWorkshop: Boolean = false, showVerifyFiles: Boolean = false, areSteamActionsEnabled: Boolean = true, + // Nullable on purpose: null hides the menu item. + onLaunchOptions: (() -> Unit)? = null, + onBetaBranches: (() -> Unit)? = null, dlcs: List = emptyList(), selectedDlcIds: Set = emptySet(), isDlcSelectionEnabled: Boolean = true, @@ -164,6 +222,100 @@ internal fun StoreGameDetailScreen( onToggleDlc: (Int) -> Unit = {}, onToggleSelectAllDlcs: () -> Unit = {}, ) { + StoreGameDetailContent( + StoreGameDetailParams( + title = title, + subtitle = subtitle, + sourceLabel = sourceLabel, + heroImageUrl = heroImageUrl, + isLoading = isLoading, + isInstalled = isInstalled, + installPathDisplay = installPathDisplay, + downloadSize = downloadSize, + installSize = installSize, + availableBytes = availableBytes, + isInstallEnabled = isInstallEnabled, + isDownloadActionEnabled = isDownloadActionEnabled, + customPathLabel = customPathLabel, + showCustomPath = showCustomPath, + showCloudSync = showCloudSync, + showUninstall = showUninstall, + showUpdateCheck = showUpdateCheck, + isCheckingForUpdate = isCheckingForUpdate, + isUpdateAvailable = isUpdateAvailable, + updateDownloadSize = updateDownloadSize, + updateStatusText = updateStatusText, + isUpdateActionEnabled = isUpdateActionEnabled, + isUpdateCheckCoolingDown = isUpdateCheckCoolingDown, + showWorkshop = showWorkshop, + showVerifyFiles = showVerifyFiles, + areSteamActionsEnabled = areSteamActionsEnabled, + onLaunchOptions = onLaunchOptions, + onBetaBranches = onBetaBranches, + dlcs = dlcs, + selectedDlcIds = selectedDlcIds, + isDlcSelectionEnabled = isDlcSelectionEnabled, + onBack = onBack, + onInstall = onInstall, + onCheckForUpdate = onCheckForUpdate, + onWorkshop = onWorkshop, + onVerifyFiles = onVerifyFiles, + onDownloadUpdate = onDownloadUpdate, + onUninstall = onUninstall, + onCloudSync = onCloudSync, + onCustomPath = onCustomPath, + onToggleDlc = onToggleDlc, + onToggleSelectAllDlcs = onToggleSelectAllDlcs, + ), + ) +} + +@Composable +private fun StoreGameDetailContent(p: StoreGameDetailParams) { + // Local rebinds keep the body identical to the pre-split parameter names. + val title = p.title + val subtitle = p.subtitle + val sourceLabel = p.sourceLabel + val heroImageUrl = p.heroImageUrl + val isLoading = p.isLoading + val isInstalled = p.isInstalled + val installPathDisplay = p.installPathDisplay + val downloadSize = p.downloadSize + val installSize = p.installSize + val availableBytes = p.availableBytes + val isInstallEnabled = p.isInstallEnabled + val isDownloadActionEnabled = p.isDownloadActionEnabled + val customPathLabel = p.customPathLabel + val showCustomPath = p.showCustomPath + val showCloudSync = p.showCloudSync + val showUninstall = p.showUninstall + val showUpdateCheck = p.showUpdateCheck + val isCheckingForUpdate = p.isCheckingForUpdate + val isUpdateAvailable = p.isUpdateAvailable + val updateDownloadSize = p.updateDownloadSize + val updateStatusText = p.updateStatusText + val isUpdateActionEnabled = p.isUpdateActionEnabled + val isUpdateCheckCoolingDown = p.isUpdateCheckCoolingDown + val showWorkshop = p.showWorkshop + val showVerifyFiles = p.showVerifyFiles + val areSteamActionsEnabled = p.areSteamActionsEnabled + val onLaunchOptions = p.onLaunchOptions + val onBetaBranches = p.onBetaBranches + val dlcs = p.dlcs + val selectedDlcIds = p.selectedDlcIds + val isDlcSelectionEnabled = p.isDlcSelectionEnabled + val onBack = p.onBack + val onInstall = p.onInstall + val onCheckForUpdate = p.onCheckForUpdate + val onWorkshop = p.onWorkshop + val onVerifyFiles = p.onVerifyFiles + val onDownloadUpdate = p.onDownloadUpdate + val onUninstall = p.onUninstall + val onCloudSync = p.onCloudSync + val onCustomPath = p.onCustomPath + val onToggleDlc = p.onToggleDlc + val onToggleSelectAllDlcs = p.onToggleSelectAllDlcs + val context = LocalContext.current val density = LocalDensity.current var dlcExpanded by remember { mutableStateOf(false) } @@ -186,7 +338,11 @@ internal fun StoreGameDetailScreen( val showUpdateCta = updateCheckAvailable && isUpdateAvailable val verifyFilesAvailable = showVerifyFiles && isInstalled val workshopAvailable = showWorkshop && isInstalled - val sourceMenuEnabled = updateCheckAvailable || verifyFilesAvailable || workshopAvailable + val launchOptionsAvailable = onLaunchOptions != null && isInstalled + val betaBranchesAvailable = onBetaBranches != null && isInstalled + val sourceMenuEnabled = + updateCheckAvailable || verifyFilesAvailable || workshopAvailable || + launchOptionsAvailable || betaBranchesAvailable val showDlcCard = dlcs.isNotEmpty() val showActionColumn = showDownloadCta || showUpdateCta || @@ -303,6 +459,8 @@ internal fun StoreGameDetailScreen( onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, + onLaunchOptions = if (launchOptionsAvailable) onLaunchOptions else null, + onBetaBranches = if (betaBranchesAvailable) onBetaBranches else null, ) } @@ -500,14 +658,72 @@ internal fun StoreGameDetailScreen( } if (showDownloadCta) { - StoreCtaButton( - height = ctaHeight, - icon = Icons.Outlined.Download, - label = stringResource(R.string.common_ui_download), - enabled = !isLoading && isDownloadActionEnabled, - loading = isLoading, - onClick = onInstall, - ) + if (onBetaBranches != null && !isInstalled) { + // Pre-install: a beta-branch segment "sliced off" the + // Download pill — pick a branch before committing. + // Each segment paints only its slice of one gradient + // spanning the whole row, so the pair reads as the + // single Download pill. + var splitCtaWidthPx by remember { mutableIntStateOf(0) } + val splitCtaLeadPx = + with(LocalDensity.current) { + (actionIconSize + actionIconSpacing).toPx() + } + Row( + modifier = + Modifier + .fillMaxWidth() + .onSizeChanged { splitCtaWidthPx = it.width }, + horizontalArrangement = Arrangement.spacedBy(actionIconSpacing), + ) { + StoreCtaCutoutButton( + height = ctaHeight, + icon = Icons.Outlined.AltRoute, + contentDescription = stringResource(R.string.store_game_beta_branch), + // Same gate as Download: don't stage picks the + // blocked download can't honor. + enabled = !isLoading && isDownloadActionEnabled, + shape = + RoundedCornerShape( + topStart = 14.dp, + bottomStart = 14.dp, + topEnd = 4.dp, + bottomEnd = 4.dp, + ), + onClick = onBetaBranches, + modifier = Modifier.width(actionIconSize), + fillEndX = + splitCtaWidthPx.takeIf { it > 0 }?.toFloat() + ?: Float.POSITIVE_INFINITY, + ) + StoreCtaButton( + height = ctaHeight, + icon = Icons.Outlined.Download, + label = stringResource(R.string.common_ui_download), + enabled = !isLoading && isDownloadActionEnabled, + loading = isLoading, + onClick = onInstall, + modifier = Modifier.weight(1f), + shape = + RoundedCornerShape( + topStart = 4.dp, + bottomStart = 4.dp, + topEnd = 14.dp, + bottomEnd = 14.dp, + ), + fillStartX = -splitCtaLeadPx, + ) + } + } else { + StoreCtaButton( + height = ctaHeight, + icon = Icons.Outlined.Download, + label = stringResource(R.string.common_ui_download), + enabled = !isLoading && isDownloadActionEnabled, + loading = isLoading, + onClick = onInstall, + ) + } } } } @@ -786,6 +1002,9 @@ private fun StoreSourceTag( onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, + // Nullable on purpose: null hides the menu item. + onLaunchOptions: (() -> Unit)? = null, + onBetaBranches: (() -> Unit)? = null, ) { var menuOpen by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableIntStateOf(0) } @@ -861,6 +1080,20 @@ private fun StoreSourceTag( enabled = areSteamActionsEnabled, ) { menuOpen = false; onWorkshop() } } + if (onLaunchOptions != null) { + StoreSourceMenuItem( + icon = Icons.Outlined.RocketLaunch, + label = stringResource(R.string.store_game_launch_options), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onLaunchOptions() } + } + if (onBetaBranches != null) { + StoreSourceMenuItem( + icon = Icons.Outlined.AltRoute, + label = stringResource(R.string.store_game_beta_branch), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onBetaBranches() } + } } } } @@ -1018,6 +1251,73 @@ private fun StoreActionChip( } } +/** Glass-look brushes shared by StoreCtaButton and StoreCtaCutoutButton. */ +private class StoreCtaGlass( + val fill: Brush, + val sheen: Brush, + val rim: Brush, +) + +private fun storeCtaGlassBrushes( + enabled: Boolean, + flare: Float, + fillStartX: Float = 0f, + fillEndX: Float = Float.POSITIVE_INFINITY, +): StoreCtaGlass = + StoreCtaGlass( + fill = + if (enabled) { + Brush.horizontalGradient( + colors = + listOf( + Color(0xFF00B4D8).copy(alpha = 0.38f), + StoreAccent.copy(alpha = 0.38f), + Color(0xFF7B2FF7).copy(alpha = 0.38f), + ), + startX = fillStartX, + endX = fillEndX, + ) + } else { + Brush.horizontalGradient( + colors = + listOf( + Color(0xFF3A3A4A).copy(alpha = 0.35f), + Color(0xFF2A2A36).copy(alpha = 0.35f), + ), + startX = fillStartX, + endX = fillEndX, + ) + }, + sheen = + if (enabled) { + Brush.verticalGradient( + 0.00f to Color.White.copy(alpha = 0.28f), + 0.35f to Color.White.copy(alpha = 0.06f), + 0.55f to Color.Transparent, + 1.00f to Color.Black.copy(alpha = 0.12f), + ) + } else { + Brush.verticalGradient( + 0.0f to Color.White.copy(alpha = 0.10f), + 0.6f to Color.Transparent, + 1.0f to Color.Black.copy(alpha = 0.08f), + ) + }, + rim = + if (enabled) { + Brush.verticalGradient( + 0.0f to Color.White.copy(alpha = 0.55f + 0.35f * flare), + 0.5f to Color.White.copy(alpha = 0.08f + 0.18f * flare), + 1.0f to Color.White.copy(alpha = 0.22f + 0.22f * flare), + ) + } else { + Brush.verticalGradient( + 0.0f to Color.White.copy(alpha = 0.16f), + 1.0f to Color.White.copy(alpha = 0.04f), + ) + }, + ) + @Composable private fun StoreCtaButton( height: Dp, @@ -1026,6 +1326,10 @@ private fun StoreCtaButton( enabled: Boolean, loading: Boolean, onClick: () -> Unit, + modifier: Modifier = Modifier.fillMaxWidth(), + shape: RoundedCornerShape = RoundedCornerShape(14.dp), + fillStartX: Float = 0f, + fillEndX: Float = Float.POSITIVE_INFINITY, ) { val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() @@ -1039,64 +1343,18 @@ private fun StoreCtaButton( animationSpec = spring(dampingRatio = 0.6f, stiffness = 500f), label = "storeCtaFlare", ) - val shape = remember { RoundedCornerShape(14.dp) } - val activeBrush = - Brush.horizontalGradient( - colors = - listOf( - Color(0xFF00B4D8).copy(alpha = 0.38f), - StoreAccent.copy(alpha = 0.38f), - Color(0xFF7B2FF7).copy(alpha = 0.38f), - ), - ) - val disabledBrush = - Brush.horizontalGradient( - colors = - listOf( - Color(0xFF3A3A4A).copy(alpha = 0.35f), - Color(0xFF2A2A36).copy(alpha = 0.35f), - ), - ) - val glassSheenBrush = - if (enabled) { - Brush.verticalGradient( - 0.00f to Color.White.copy(alpha = 0.28f), - 0.35f to Color.White.copy(alpha = 0.06f), - 0.55f to Color.Transparent, - 1.00f to Color.Black.copy(alpha = 0.12f), - ) - } else { - Brush.verticalGradient( - 0.0f to Color.White.copy(alpha = 0.10f), - 0.6f to Color.Transparent, - 1.0f to Color.Black.copy(alpha = 0.08f), - ) - } - val glassRimBrush = - if (enabled) { - Brush.verticalGradient( - 0.0f to Color.White.copy(alpha = 0.55f + 0.35f * flare), - 0.5f to Color.White.copy(alpha = 0.08f + 0.18f * flare), - 1.0f to Color.White.copy(alpha = 0.22f + 0.22f * flare), - ) - } else { - Brush.verticalGradient( - 0.0f to Color.White.copy(alpha = 0.16f), - 1.0f to Color.White.copy(alpha = 0.04f), - ) - } + val glass = storeCtaGlassBrushes(enabled, flare, fillStartX, fillEndX) Box( modifier = - Modifier - .fillMaxWidth() + modifier .height(height) .graphicsLayer { scaleX = scale scaleY = scale }.clip(shape) - .background(if (enabled) activeBrush else disabledBrush) - .background(glassSheenBrush) - .border(1.dp, glassRimBrush, shape) + .background(glass.fill) + .background(glass.sheen) + .border(1.dp, glass.rim, shape) .clickable( interactionSource = interactionSource, indication = null, @@ -1134,6 +1392,62 @@ private fun StoreCtaButton( } } +/** + * The small segment "sliced off" the Download pill: same glass look, icon only. + * Opens the beta-branch picker before a download is committed. + */ +@Composable +private fun StoreCtaCutoutButton( + height: Dp, + icon: ImageVector, + contentDescription: String, + enabled: Boolean, + shape: RoundedCornerShape, + onClick: () -> Unit, + modifier: Modifier = Modifier, + fillStartX: Float = 0f, + fillEndX: Float = Float.POSITIVE_INFINITY, +) { + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed && enabled) 0.96f else 1f, + animationSpec = spring(dampingRatio = 0.5f, stiffness = 600f), + label = "storeCtaCutoutScale", + ) + val flare by animateFloatAsState( + targetValue = if (isPressed && enabled) 1f else 0f, + animationSpec = spring(dampingRatio = 0.6f, stiffness = 500f), + label = "storeCtaCutoutFlare", + ) + val glass = storeCtaGlassBrushes(enabled, flare, fillStartX, fillEndX) + Box( + modifier = + modifier + .height(height) + .graphicsLayer { + scaleX = scale + scaleY = scale + }.clip(shape) + .background(glass.fill) + .background(glass.sheen) + .border(1.dp, glass.rim, shape) + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = { if (enabled) onClick() }, + ), + contentAlignment = Alignment.Center, + ) { + Icon( + icon, + contentDescription = contentDescription, + modifier = Modifier.size(24.dp), + tint = Color.White, + ) + } +} + @Composable private fun StoreIconActionButton( icon: ImageVector, diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 80a3e4e86..24a1cae8a 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -217,6 +217,7 @@ import com.winlator.cmod.shared.theme.WinNativeTheme import dagger.hilt.android.AndroidEntryPoint import dagger.Lazy import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -371,6 +372,7 @@ class UnifiedActivity : lifecycleScope.launch { val result = runCatching { + // checkForAppUpdate defaults to the game's selected beta branch. withContext(Dispatchers.IO) { SteamService.checkForAppUpdate(appId) } }.getOrNull() try { @@ -382,8 +384,16 @@ class UnifiedActivity : } result.hasUpdate -> { val started = - withContext(Dispatchers.IO) { - SteamService.downloadAppForUpdate(appId, result.depotIds) + runCatching { + withContext(Dispatchers.IO) { + SteamService.downloadAppForUpdate(appId, result.depotIds) + } + }.getOrElse { e -> + Log.w("UnifiedActivity", "Steam update download failed to start for appId=$appId", e) + taskCheckingShown = false + taskDoneFailed = true + taskDoneMessage = getString(R.string.store_game_update_failed_notice) + return@launch } if (started != null) { showTaskProgressPopup( @@ -4585,6 +4595,39 @@ class UnifiedActivity : val isEpic = app.id >= 2000000000 val isGog = gogGame != null val epicId = if (isEpic) app.id - 2000000000 else 0 + val isSteamLibraryGame = !isCustom && !isEpic && !isGog + + var showLaunchOptionsDialog by remember(app.id) { mutableStateOf(false) } + var launchOptions by remember(app.id) { mutableStateOf>(emptyList()) } + var selectedLaunchOption by remember(app.id) { mutableStateOf(null) } + var showBetaBranchesDialog by remember(app.id) { mutableStateOf(false) } + var betaBranches by remember(app.id) { mutableStateOf>(emptyList()) } + var selectedBetaBranch by remember(app.id) { mutableStateOf(null) } + LaunchedEffect(app.id, isSteamLibraryGame) { + val steamInstalled = + isSteamLibraryGame && withContext(Dispatchers.IO) { SteamService.isAppInstalled(app.id) } + if (!steamInstalled) { + launchOptions = emptyList() + selectedLaunchOption = null + betaBranches = emptyList() + selectedBetaBranch = null + return@LaunchedEffect + } + // Degrade to hidden menu items (logged) rather than crash the dialog. + runCatching { + loadSteamLaunchOptionsRefreshing(app.id) { options, selected -> + launchOptions = options + selectedLaunchOption = selected + } + loadSteamBetaBranchesRefreshing(app.id) { branches, selected -> + betaBranches = branches + selectedBetaBranch = selected + } + }.onFailure { e -> + if (e is kotlinx.coroutines.CancellationException) throw e + Log.e("UnifiedActivity", "Steam game-detail extras load failed for appId=${app.id}", e) + } + } val libraryDownloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState( initial = com.winlator.cmod.app.service.download.DownloadCoordinator.snapshotRecords(), @@ -5313,6 +5356,18 @@ class UnifiedActivity : (!isEpic || epicGame?.isInstalled == true) && (!isGog || gogGame?.isInstalled == true), showWorkshop = !isEpic && !isGog, + onLaunchOptions = + if (launchOptions.size >= 2) { + { showLaunchOptionsDialog = true } + } else { + null + }, + onBetaBranches = + if (betaBranches.size >= 2) { + { showBetaBranchesDialog = true } + } else { + null + }, areSteamActionsEnabled = when { isEpic -> !hasBlockingEpicDownloadForLibrary @@ -5793,6 +5848,40 @@ class UnifiedActivity : onDismissRequest = { showWorkshopDialog = false }, ) } + + if (showLaunchOptionsDialog) { + LaunchOptionsDialog( + appId = app.id, + gameTitle = app.name, + options = launchOptions, + selectedOption = selectedLaunchOption, + onSelectionSaved = { selectedLaunchOption = it }, + onDismissRequest = { showLaunchOptionsDialog = false }, + ) + } + + if (showBetaBranchesDialog) { + BetaBranchesDialog( + gameTitle = app.name, + branches = betaBranches, + selectedBranch = selectedBetaBranch, + onSelect = { item -> + persistSteamBetaBranchSelection( + appId = app.id, + item = item, + scope = scope, + onSaved = { saved -> + selectedBetaBranch = saved + // Close the picker so the update-check flow it + // triggers is what the user sees next. + showBetaBranchesDialog = false + }, + startUpdate = { startUpdateCheck(app.id, app.name) }, + ) + }, + onDismissRequest = { showBetaBranchesDialog = false }, + ) + } } } } @@ -7724,6 +7813,7 @@ class UnifiedActivity : private data class DownloadCancelRequest( val ids: List, val isCancelAll: Boolean, + val isRepair: Boolean = false, ) // Downloads Tab @@ -7900,12 +7990,16 @@ class UnifiedActivity : DownloadCancelRequest( ids = pausableDownloads.map { it.first }, isCancelAll = true, + isRepair = pausableDownloads.any { it.second.isRepair }, ) } else { cancelWarningRequest = DownloadCancelRequest( ids = listOf(selectedId), isCancelAll = false, + isRepair = + downloads.firstOrNull { it.first == selectedId } + ?.second?.isRepair == true, ) } }, @@ -7928,6 +8022,7 @@ class UnifiedActivity : onSelectDownload(null) }, isCancelAll = request.isCancelAll, + isRepair = request.isRepair, ) } } @@ -8433,10 +8528,26 @@ class UnifiedActivity : onDismissRequest: () -> Unit, onConfirm: () -> Unit, isCancelAll: Boolean = false, + isRepair: Boolean = false, ) { - val titleRes = if (isCancelAll) R.string.downloads_queue_cancel_all_title else R.string.downloads_queue_cancel_download_title - val messageRes = if (isCancelAll) R.string.downloads_queue_cancel_all_warning else R.string.downloads_queue_cancel_download_warning - val confirmRes = if (isCancelAll) R.string.downloads_queue_cancel_all else R.string.downloads_queue_cancel_download + val titleRes = + when { + isRepair -> R.string.downloads_queue_cancel_restore_title + isCancelAll -> R.string.downloads_queue_cancel_all_title + else -> R.string.downloads_queue_cancel_download_title + } + val messageRes = + when { + isRepair -> R.string.downloads_queue_cancel_restore_warning + isCancelAll -> R.string.downloads_queue_cancel_all_warning + else -> R.string.downloads_queue_cancel_download_warning + } + val confirmRes = + when { + isRepair -> R.string.downloads_queue_cancel_restore + isCancelAll -> R.string.downloads_queue_cancel_all + else -> R.string.downloads_queue_cancel_download + } LaunchDangerConfirmDialog( visible = expanded, title = stringResource(titleRes), @@ -8619,8 +8730,21 @@ class UnifiedActivity : } } + val activeRepairPhases = + setOf( + DownloadPhase.DOWNLOADING, + DownloadPhase.VERIFYING, + DownloadPhase.PREPARING, + DownloadPhase.PATCHING, + DownloadPhase.APPLYING_DATA, + DownloadPhase.FINALIZING, + DownloadPhase.UNPACKING, + DownloadPhase.QUEUED, + ) val statusText = - when (status) { + if (info.isRepair && status in activeRepairPhases) { + stringResource(R.string.downloads_queue_phase_restoring) + } else when (status) { DownloadPhase.DOWNLOADING -> { currentFile?.let { stringResource(R.string.downloads_queue_phase_downloading_file, it.take(10)) @@ -8771,6 +8895,7 @@ class UnifiedActivity : showDeleteDialog = false DownloadService.cancelDownload(id) }, + isRepair = info.isRepair, ) } } @@ -8782,6 +8907,191 @@ class UnifiedActivity : } } + // Apps whose appinfo was already re-fetched this process (see + // refreshAppInfoFromPicsOnce) — avoids a PICS round-trip on every + // game-detail open. + private val appinfoRefreshedApps = java.util.Collections.synchronizedSet(mutableSetOf()) + + /** + * Builds the Steam launch-option list (appinfo config.launch) for the STEAM + * dropdown on both game detail screens, plus the currently effective selection. + */ + private suspend fun loadSteamLaunchOptions(appId: Int): Pair, StoreLaunchOptionItem?> = + withContext(Dispatchers.IO) { + val appDir = java.io.File(SteamService.getAppDirPath(appId)) + val allOptions = + SteamService + .getWindowsLaunchInfos(appId) + .map { info -> + StoreLaunchOptionItem( + executable = info.executable, + arguments = info.arguments, + label = info.description.ifBlank { info.executable.substringAfterLast('/') }, + ) + } + // Label is part of the key: cached appinfo rows predating + // LaunchInfo.arguments have "" args, and exe+args alone would + // collapse distinct options like "Play (DX11)" / "Play (DX12)". + .distinctBy { Triple(it.executable.lowercase(), it.arguments, it.label) } + // Hide options whose exe is missing on disk, but if that empties a + // non-empty list (case-sensitivity quirks) keep the unfiltered set. + val onDisk = allOptions.filter { java.io.File(appDir, it.executable).isFile } + val options = onDisk.ifEmpty { allOptions } + val (selectedExe, selectedArgs) = SteamService.getSelectedLaunchOption(applicationContext, appId) + val selected = + options.firstOrNull { + it.executable.equals(selectedExe, ignoreCase = true) && it.arguments == selectedArgs + } ?: options.firstOrNull { it.executable.equals(selectedExe, ignoreCase = true) } + ?: options.firstOrNull() + options to selected + } + + /** + * Re-fetches [appId]'s PICS appinfo at most once per process — shared by + * the launch-options and beta-branch loaders so one round-trip feeds both. + * Returns true when this call performed the refresh; a failed fetch + * (offline) releases the slot so the next game-detail open retries. + */ + private suspend fun refreshAppInfoFromPicsOnce(appId: Int): Boolean { + if (!appinfoRefreshedApps.add(appId)) return false + if (SteamService.refreshAppInfoFromPics(appId)) return true + appinfoRefreshedApps.remove(appId) + return false + } + + /** + * Loads launch options, then — once per app per process — re-fetches the + * app's PICS appinfo to heal cached rows that predate LaunchInfo.arguments + * and re-applies the fresh list. [apply] runs on the caller's context. + */ + private suspend fun loadSteamLaunchOptionsRefreshing( + appId: Int, + apply: (List, StoreLaunchOptionItem?) -> Unit, + ) { + val (options, selected) = loadSteamLaunchOptions(appId) + apply(options, selected) + if (refreshAppInfoFromPicsOnce(appId)) { + val (fresh, freshSelected) = loadSteamLaunchOptions(appId) + apply(fresh, freshSelected) + } + } + + private fun persistSteamLaunchOptionSelection( + appId: Int, + option: StoreLaunchOptionItem, + scope: CoroutineScope, + onSaved: (StoreLaunchOptionItem) -> Unit, + ) { + scope.launch(Dispatchers.IO) { + val saved = + SteamService.setSelectedLaunchOption( + applicationContext, + appId, + option.executable, + option.arguments, + ) + withContext(Dispatchers.Main) { + if (saved) { + onSaved(option) + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + this@UnifiedActivity, + getString(R.string.store_game_launch_option_failed), + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } + } + + private suspend fun loadSteamBetaBranches(appId: Int): Pair, StoreBetaBranchItem?> = + withContext(Dispatchers.IO) { + val branches = + SteamService.getAppInfoOf(appId) + ?.branches + ?.values + ?.map { b -> StoreBetaBranchItem(b.name, b.buildId, b.timeUpdated, b.pwdRequired) } + ?.sortedWith(compareBy({ !it.name.equals("public", ignoreCase = true) }, { it.name })) + .orEmpty() + // recoverSelectedBetaName also heals selections lost to an app + // reinstall (shortcuts are app-private; game files survive). + val selectedName = SteamService.recoverSelectedBetaName(appId).ifBlank { "public" } + val selected = + branches.firstOrNull { it.name.equals(selectedName, ignoreCase = true) } + // Selected beta may have been retired from PICS — fall back to public. + ?: branches.firstOrNull { it.name.equals("public", ignoreCase = true) } + branches to selected + } + + /** + * Loads beta branches, then — once per app per process — re-fetches the + * app's PICS appinfo for current branch build ids and re-applies the + * fresh list. [apply] runs on the caller's context. + */ + private suspend fun loadSteamBetaBranchesRefreshing( + appId: Int, + apply: (List, StoreBetaBranchItem?) -> Unit, + ) { + val (branches, selected) = loadSteamBetaBranches(appId) + apply(branches, selected) + if (refreshAppInfoFromPicsOnce(appId)) { + val (fresh, freshSelected) = loadSteamBetaBranches(appId) + apply(fresh, freshSelected) + } + } + + /** + * Commits a staged pre-install branch pick right before the download starts, + * so downloadApp (and any later resume) resolves it from the shortcut. + * Custom path is persisted first: the shortcut snapshots game_install_path + * at creation and is never rewritten. On failure (no usable container yet) + * the install proceeds on the public branch rather than blocking. + */ + private suspend fun commitPendingBetaBranch( + appId: Int, + item: StoreBetaBranchItem, + customPath: String?, + ) { + customPath?.let { SteamService.setCustomInstallPath(appId, it) } + val branchName = if (item.name.equals("public", ignoreCase = true)) "" else item.name + if (!SteamService.setSelectedBetaBranch(applicationContext, appId, branchName)) { + withContext(Dispatchers.Main) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + this@UnifiedActivity, + getString(R.string.store_game_beta_branch_failed), + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } + + private fun persistSteamBetaBranchSelection( + appId: Int, + item: StoreBetaBranchItem, + scope: CoroutineScope, + onSaved: (StoreBetaBranchItem) -> Unit, + startUpdate: () -> Unit, + ) { + scope.launch(Dispatchers.IO) { + // "public" is the implicit default — store blank so resolveSelectedBetaName + // keeps returning "" for games the user never switched. + val branchName = if (item.name.equals("public", ignoreCase = true)) "" else item.name + val saved = SteamService.setSelectedBetaBranch(applicationContext, appId, branchName) + withContext(Dispatchers.Main) { + if (saved) { + onSaved(item) + startUpdate() + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + this@UnifiedActivity, + getString(R.string.store_game_beta_branch_failed), + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } + } + // Game Manager Dialog @Composable fun GameManagerDialog( @@ -8801,6 +9111,14 @@ class UnifiedActivity : var isCheckingForUpdate by remember(app.id) { mutableStateOf(false) } var isUpdateCheckCoolingDown by remember(app.id) { mutableStateOf(false) } var showWorkshopDialog by remember(app.id) { mutableStateOf(false) } + var showLaunchOptionsDialog by remember(app.id) { mutableStateOf(false) } + var launchOptions by remember(app.id) { mutableStateOf>(emptyList()) } + var selectedLaunchOption by remember(app.id) { mutableStateOf(null) } + var showBetaBranchesDialog by remember(app.id) { mutableStateOf(false) } + var betaBranches by remember(app.id) { mutableStateOf>(emptyList()) } + var selectedBetaBranch by remember(app.id) { mutableStateOf(null) } + // Pre-install branch choice, staged until the Download button commits it. + var pendingBetaBranch by remember(app.id) { mutableStateOf(null) } var updateInfo by remember(app.id) { mutableStateOf(null) } var updateStatusText by remember(app.id) { mutableStateOf(null) } val downloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState( @@ -8830,13 +9148,18 @@ class UnifiedActivity : val installed: Boolean, ) - LaunchedEffect(app.id, downloadRecords) { + LaunchedEffect(app.id, downloadRecords, pendingBetaBranch) { val loadData = withContext(Dispatchers.IO) { + // Size the same branch the download will actually fetch — the + // staged pre-install pick wins over the persisted selection. + val branch = + pendingBetaBranch?.name + ?: SteamService.resolveSelectedBetaName(app.id).ifBlank { "public" } val selectableDlcApps = SteamService.getSelectableDlcAppsOf(app.id) val perDlcSizes = selectableDlcApps.associate { dlc -> - dlc.id to SteamService.getDlcOnlyManifestSizes(app.id, dlc.id) + dlc.id to SteamService.getDlcOnlyManifestSizes(app.id, dlc.id, branch = branch) } val installedDlcIds = SteamService.getInstalledDlcDepotsOf(app.id) @@ -8846,7 +9169,7 @@ class UnifiedActivity : dlcApps = selectableDlcApps, dlcSizes = perDlcSizes, installedDlcIds = installedDlcIds, - baseManifestSizes = SteamService.getInstallableSelectedManifestSizes(app.id), + baseManifestSizes = SteamService.getInstallableSelectedManifestSizes(app.id, branch = branch), installed = SteamService.isAppInstalled(app.id), ) } @@ -8859,11 +9182,46 @@ class UnifiedActivity : isLoading = false } - LaunchedEffect(app.id, selectedDlcIds.toList()) { + LaunchedEffect(app.id, selectedDlcIds.toList(), pendingBetaBranch) { selectedManifestSizes = withContext(Dispatchers.IO) { - SteamService.getInstallableSelectedManifestSizes(app.id, selectedDlcIds.toList()) + val branch = + pendingBetaBranch?.name + ?: SteamService.resolveSelectedBetaName(app.id).ifBlank { "public" } + SteamService.getInstallableSelectedManifestSizes(app.id, selectedDlcIds.toList(), branch = branch) + } + } + + LaunchedEffect(app.id, installed) { + // Wait for the install state so the pre-install branch cutout can't + // flash on a game that then resolves to installed. + if (installed == null) return@LaunchedEffect + if (installed == true) { + // A staged pre-install pick is meaningless once the game is + // installed (it was either committed by Download or abandoned). + pendingBetaBranch = null + } else { + launchOptions = emptyList() + selectedLaunchOption = null + } + // Degrade to hidden menu items (logged) rather than crash the dialog. + runCatching { + if (installed == true) { + loadSteamLaunchOptionsRefreshing(app.id) { options, selected -> + launchOptions = options + selectedLaunchOption = selected + } + } + // Branches load for both states: installed games switch via the + // STEAM menu, uninstalled games via the Download-button cutout. + loadSteamBetaBranchesRefreshing(app.id) { branches, selected -> + betaBranches = branches + selectedBetaBranch = selected } + }.onFailure { e -> + if (e is kotlinx.coroutines.CancellationException) throw e + Log.e("UnifiedActivity", "Steam game-detail extras load failed for appId=${app.id}", e) + } } val totalDownloadSize = selectedManifestSizes.downloadSize @@ -8986,6 +9344,18 @@ class UnifiedActivity : showWorkshop = isReallyInstalled, showVerifyFiles = isReallyInstalled, areSteamActionsEnabled = !hasBlockingSteamDownload, + onLaunchOptions = + if (launchOptions.size >= 2) { + { showLaunchOptionsDialog = true } + } else { + null + }, + onBetaBranches = + if (betaBranches.size >= 2) { + { showBetaBranchesDialog = true } + } else { + null + }, dlcs = dlcItems, selectedDlcIds = selectedDlcIds.toSet(), isDlcSelectionEnabled = steamDownloadRecord == null, @@ -9004,6 +9374,9 @@ class UnifiedActivity : val installableDlcIds = dlcItems .filter { !it.isInstalled && it.id in selectedDlcIds } .map { it.id } + if (installed != true) { + pendingBetaBranch?.let { commitPendingBetaBranch(app.id, it, customPath) } + } SteamService.downloadApp(app.id, installableDlcIds, false, customPath) withContext(Dispatchers.Main) { onDismissRequest() } } @@ -9124,6 +9497,114 @@ class UnifiedActivity : onDismissRequest = { showWorkshopDialog = false }, ) } + + if (showLaunchOptionsDialog) { + LaunchOptionsDialog( + appId = app.id, + gameTitle = app.name, + options = launchOptions, + selectedOption = selectedLaunchOption, + onSelectionSaved = { selectedLaunchOption = it }, + onDismissRequest = { showLaunchOptionsDialog = false }, + ) + } + + if (showBetaBranchesDialog) { + BetaBranchesDialog( + gameTitle = app.name, + branches = betaBranches, + // pendingBetaBranch is null for installed games (cleared on install). + selectedBranch = pendingBetaBranch ?: selectedBetaBranch, + onSelect = { item -> + if (installed == true) { + persistSteamBetaBranchSelection( + appId = app.id, + item = item, + scope = scope, + onSaved = { saved -> + selectedBetaBranch = saved + // Close the picker so the update-check flow it + // triggers is what the user sees next. + showBetaBranchesDialog = false + }, + startUpdate = { startUpdateCheck(app.id, app.name) }, + ) + } else { + // Pre-install: stage only — the Download button commits. + pendingBetaBranch = item + showBetaBranchesDialog = false + } + }, + onDismissRequest = { showBetaBranchesDialog = false }, + ) + } + } + + /** + * Hosts the Workshop-styled launch-option picker window over a game detail + * dialog. Selecting a row persists it as the game's default and reports the + * saved option through [onSelectionSaved]. + */ + @Composable + private fun LaunchOptionsDialog( + appId: Int, + gameTitle: String, + options: List, + selectedOption: StoreLaunchOptionItem?, + onSelectionSaved: (StoreLaunchOptionItem) -> Unit, + onDismissRequest: () -> Unit, + ) { + val scope = rememberCoroutineScope() + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + StoreLaunchOptionsScreen( + gameTitle = gameTitle, + options = options, + selectedOption = selectedOption, + onSelect = { option -> + persistSteamLaunchOptionSelection(appId, option, scope, onSelectionSaved) + }, + onClose = onDismissRequest, + ) + } + } + + /** + * Hosts the Workshop-styled beta-branch picker window over a game detail + * dialog. What a row tap means is the host's call via [onSelect]: installed + * games persist + start the update flow, pre-install games just stage the + * choice until the Download button commits it. + */ + @Composable + private fun BetaBranchesDialog( + gameTitle: String, + branches: List, + selectedBranch: StoreBetaBranchItem?, + onSelect: (StoreBetaBranchItem) -> Unit, + onDismissRequest: () -> Unit, + ) { + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + StoreBetaBranchScreen( + gameTitle = gameTitle, + branches = branches, + selectedBranch = selectedBranch, + onSelect = onSelect, + onClose = onDismissRequest, + ) + } } @Composable diff --git a/app/src/main/cpp/wn-steam-client/rust/src/depot_cleanup.rs b/app/src/main/cpp/wn-steam-client/rust/src/depot_cleanup.rs new file mode 100644 index 000000000..a2073b21e --- /dev/null +++ b/app/src/main/cpp/wn-steam-client/rust/src/depot_cleanup.rs @@ -0,0 +1,1065 @@ +use crate::content_manifest::ContentManifest; +use crate::depot_config::{atomic_write_synced, DepotConfigStore, INVALID_MANIFEST_ID}; +use crate::depot_downloader::ResolvedDepotSpec; +use crate::depot_writer::DEPOT_FILE_FLAG_DIRECTORY; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +const STALE_CLEANUP_SUFFIX: &str = ".stalecleanup"; +const FILELIST_SUFFIX: &str = ".filelist"; +const FILELIST_HEADER: &str = "WNFL1"; + +fn cleanup_log(message: &str) { + crate::jni::android_log("WnSteamDepotCleanup", message); +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct FileEntry { + pub name: String, + pub is_dir: bool, +} + +pub fn stale_cleanup_marker_name(depot_id: u32, manifest_id: u64) -> String { + format!("{depot_id}_{manifest_id}{STALE_CLEANUP_SUFFIX}") +} + +pub fn stale_cleanup_marker_path( + config_dir: impl AsRef, + depot_id: u32, + manifest_id: u64, +) -> PathBuf { + config_dir + .as_ref() + .join(stale_cleanup_marker_name(depot_id, manifest_id)) +} + +pub fn filelist_sidecar_path( + config_dir: impl AsRef, + depot_id: u32, + manifest_id: u64, +) -> PathBuf { + config_dir + .as_ref() + .join(format!("{depot_id}_{manifest_id}{FILELIST_SUFFIX}")) +} + +/// Persists a manifest's decrypted file list next to its cache. The stale-file +/// pass reads these instead of the raw manifests, so it never needs depot keys +/// for depots outside the current download operation (narrowed updates and +/// per-app DLC batches only carry keys for their own depots). +pub fn write_filelist_sidecar( + config_dir: impl AsRef, + depot_id: u32, + manifest_id: u64, + manifest: &ContentManifest, +) -> bool { + let mut blob = String::with_capacity(manifest.files.len() * 32 + 8); + blob.push_str(FILELIST_HEADER); + blob.push('\n'); + for file in &manifest.files { + if file.filename.contains('\n') || file.filename.contains('\r') { + continue; + } + let kind = if (file.flags & DEPOT_FILE_FLAG_DIRECTORY) != 0 { + 'D' + } else { + 'F' + }; + blob.push(kind); + blob.push(' '); + blob.push_str(&file.filename); + blob.push('\n'); + } + atomic_write_synced( + &filelist_sidecar_path(config_dir, depot_id, manifest_id), + blob.as_bytes(), + ) +} + +/// Writes the sidecar for an already-installed depot that predates sidecars, +/// using the cached manifest and this operation's key. No-op when the sidecar +/// exists or the manifest cache is unreadable. +pub fn backfill_filelist_sidecar(config_dir: &Path, depot: &ResolvedDepotSpec) { + if filelist_sidecar_path(config_dir, depot.depot_id, depot.manifest_id).is_file() { + return; + } + if let Some(manifest) = load_manifest( + config_dir, + depot.depot_id, + depot.manifest_id, + &depot.depot_key, + ) { + let _ = write_filelist_sidecar(config_dir, depot.depot_id, depot.manifest_id, &manifest); + } +} + +fn read_filelist_sidecar( + config_dir: &Path, + depot_id: u32, + manifest_id: u64, +) -> Option> { + let blob = fs::read_to_string(filelist_sidecar_path(config_dir, depot_id, manifest_id)).ok()?; + let mut lines = blob.lines(); + if lines.next() != Some(FILELIST_HEADER) { + return None; + } + let mut entries = Vec::new(); + for line in lines { + let (kind, name) = match (line.get(..2), line.get(2..)) { + (Some("F "), Some(name)) => (false, name), + (Some("D "), Some(name)) => (true, name), + _ => continue, + }; + entries.push(FileEntry { + name: name.to_string(), + is_dir: kind, + }); + } + Some(entries) +} + +/// Records that a depot is about to move off `old_manifest_id`, so the files +/// the old manifest installed can be diffed away once the new build is fully +/// on disk. The marker survives pause/cancel — cleanup only ever runs after a +/// fully successful download, and a leftover marker is retried then. +pub fn record_pending_cleanup( + config_dir: impl AsRef, + depot_id: u32, + old_manifest_id: u64, + new_manifest_id: u64, +) -> bool { + if old_manifest_id == 0 + || old_manifest_id == INVALID_MANIFEST_ID + || old_manifest_id == new_manifest_id + { + return false; + } + write_cleanup_marker(config_dir, depot_id, old_manifest_id) +} + +/// Marks a build whose write started but never committed: its files may be +/// partially on disk with no depot.config entry that would ever diff them +/// away. The marker resolves like any other — dropped if the build later +/// commits (resume), otherwise its unique files are reclaimed after the next +/// successful download (e.g. a revert-to-previous-branch verify). +pub fn record_aborted_build(config_dir: impl AsRef, depot_id: u32, manifest_id: u64) -> bool { + if manifest_id == 0 || manifest_id == INVALID_MANIFEST_ID { + return false; + } + write_cleanup_marker(config_dir, depot_id, manifest_id) +} + +fn write_cleanup_marker(config_dir: impl AsRef, depot_id: u32, manifest_id: u64) -> bool { + let path = stale_cleanup_marker_path(config_dir, depot_id, manifest_id); + let Some(parent) = path.parent() else { + return false; + }; + if fs::create_dir_all(parent).is_err() { + return false; + } + fs::write(path, manifest_id.to_string()).is_ok() +} + +pub fn pending_cleanup_markers(config_dir: impl AsRef) -> Vec<(u32, u64)> { + let Ok(entries) = fs::read_dir(config_dir.as_ref()) else { + return Vec::new(); + }; + let mut markers = BTreeSet::new(); + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + let Some(stem) = name.strip_suffix(STALE_CLEANUP_SUFFIX) else { + continue; + }; + let mut parts = stem.split('_'); + let (Some(depot), Some(gid), None) = (parts.next(), parts.next(), parts.next()) else { + continue; + }; + if let (Ok(depot_id), Ok(manifest_id)) = (depot.parse::(), gid.parse::()) { + markers.insert((depot_id, manifest_id)); + } + } + markers.into_iter().collect() +} + +fn remove_cleanup_marker(config_dir: impl AsRef, depot_id: u32, manifest_id: u64) { + let _ = fs::remove_file(stale_cleanup_marker_path(config_dir, depot_id, manifest_id)); +} + +/// Deletes game files that the previous manifests installed but no current +/// manifest references — the gap left when a branch switch (or an update that +/// removes files) only ever writes new content. +/// +/// Safety model: deletion candidates come exclusively from cached manifest +/// file lists (filelist sidecars, or cached manifests when this op holds the +/// key), minus the union of every currently-installed manifest's files. Files +/// WinNative or the user adds that appear in NO Steam manifest (steam_settings/, +/// .DepotDownloader/, *.original.exe backups, saves, and user-added mods) can +/// never become candidates, so mods that drop new files survive a switch. A +/// file a mod *overwrote* is reverted by the download itself, as on real Steam. +/// +/// Every cached old build is swept (not only those with a pending marker), so +/// orphans are reclaimed even when a marker was dropped or never recorded — +/// including files unique to a build whose write was aborted mid-switch. The +/// whole pass defers if the keep-union can't be made complete (an installed +/// depot in progress or with an unreadable file list), since a file that moved +/// into an unknown build can't then be proven safe to delete. +/// +/// Returns the number of files deleted. +pub fn run_stale_file_cleanup( + install_dir: &str, + config_dir: &Path, + depots: &[ResolvedDepotSpec], +) -> u32 { + let keys: BTreeMap = depots + .iter() + .map(|depot| (depot.depot_id, depot.depot_key.as_slice())) + .collect(); + let cfg = DepotConfigStore::load(config_dir); + + // Every old build still on disk: each cached filelist/manifest plus any + // pending marker. Sweeping cached builds (not just marked ones) reclaims + // orphans even when a marker was dropped or never recorded. + let mut old_builds = collect_cached_builds(config_dir); + old_builds.extend(pending_cleanup_markers(config_dir)); + if old_builds.is_empty() { + return 0; + } + + // The keep-union must be COMPLETE — the files of every installed depot's + // current build. A candidate not in keep is only safe to delete if no + // current or in-flight build ships it, and proving that requires reading + // every installed depot. If any is in progress (INVALID) or its file list + // is unreadable, a live file that moved between depots could be deleted, so + // defer the whole pass; a later op (finished depots / with the keys) + // rebuilds it. This never blocks the restore, where the verify writes a + // sidecar for every depot before cleanup runs. + let mut keep = BTreeSet::new(); + let mut current: BTreeMap = BTreeMap::new(); + for (depot_id, gid) in cfg.installed_entries() { + if gid == INVALID_MANIFEST_ID { + cleanup_log(&format!( + "cleanup: depot {depot_id} in progress, deferring stale-file pass" + )); + return 0; + } + match load_file_entries(config_dir, depot_id, gid, keys.get(&depot_id).copied()) { + Some(entries) => { + for entry in &entries { + keep.insert(normalized_key(&entry.name)); + } + current.insert(depot_id, gid); + } + None => { + cleanup_log(&format!( + "cleanup: installed depot {depot_id}_{gid} file list unreadable, \ + deferring stale-file pass" + )); + return 0; + } + } + } + + let install_root = Path::new(install_dir); + let mut deleted = 0u32; + for (depot_id, gid) in old_builds { + if current.get(&depot_id) == Some(&gid) { + // This build is the one currently installed — not stale. + remove_cleanup_marker(config_dir, depot_id, gid); + continue; + } + let Some(entries) = + load_file_entries(config_dir, depot_id, gid, keys.get(&depot_id).copied()) + else { + if filelist_sidecar_path(config_dir, depot_id, gid).is_file() + || config_dir + .join(format!("{depot_id}_{gid}.manifest")) + .is_file() + { + // The data is on disk but this op can't read it (no key yet); + // a later op that carries the key finishes the job. + cleanup_log(&format!( + "cleanup: old file list {depot_id}_{gid} unreadable in this op, deferring" + )); + } else { + cleanup_log(&format!( + "cleanup: old file list {depot_id}_{gid} is gone, dropping marker" + )); + remove_cleanup_marker(config_dir, depot_id, gid); + } + continue; + }; + deleted += delete_build_orphans(install_root, depot_id, gid, &entries, &keep); + remove_cleanup_marker(config_dir, depot_id, gid); + } + if deleted > 0 { + cleanup_log(&format!( + "cleanup: removed {deleted} stale file(s) under '{install_dir}'" + )); + } + deleted +} + +/// Deletes the files of one old build that no current build still ships. +fn delete_build_orphans( + install_root: &Path, + depot_id: u32, + gid: u64, + entries: &[FileEntry], + keep: &BTreeSet, +) -> u32 { + let mut deleted = 0u32; + let mut dirs = BTreeSet::new(); + for entry in entries { + let key = normalized_key(&entry.name); + if keep.contains(&key) { + continue; + } + let Some(parts) = sanitized_components(&entry.name) else { + cleanup_log(&format!( + "cleanup: refusing unsafe manifest path '{}'", + entry.name + )); + continue; + }; + if has_symlinked_ancestor(install_root, &parts) { + cleanup_log(&format!( + "cleanup: '{}' is behind a symlinked directory, skipping", + entry.name + )); + continue; + } + let mut path = install_root.to_path_buf(); + path.extend(&parts); + if entry.is_dir { + dirs.insert(path); + continue; + } + if delete_stale_file(&path) { + cleanup_log(&format!( + "cleanup: deleted '{}' (depot {depot_id}, gone after manifest {gid})", + path.display() + )); + deleted += 1; + // Steamless backs up patched exes as ".original.exe"; + // restoreOriginalExecutable would resurrect a deleted exe from an + // orphaned backup, so the backup goes with its primary — but only + // an exe's backup, and never one a current manifest legitimately + // ships. + if key.ends_with(".exe") && !keep.contains(&format!("{key}.original.exe")) { + let backup = sibling_original_backup(&path); + if delete_stale_file(&backup) { + cleanup_log(&format!("cleanup: deleted backup '{}'", backup.display())); + deleted += 1; + } + } + if let Some(parent) = path.parent() { + if parent != install_root { + dirs.insert(parent.to_path_buf()); + } + } + } + } + for dir in dirs.iter().rev() { + prune_empty_dirs_up(install_root, dir); + } + deleted +} + +/// Every (depot_id, gid) with a cached filelist sidecar or manifest — the set +/// of builds whose file lists this pass can diff against the current install. +fn collect_cached_builds(config_dir: &Path) -> BTreeSet<(u32, u64)> { + let mut builds = BTreeSet::new(); + let Ok(entries) = fs::read_dir(config_dir) else { + return builds; + }; + for entry in entries.flatten() { + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + let stem = match name.strip_suffix(FILELIST_SUFFIX) { + Some(stem) => stem, + None => match name.strip_suffix(".manifest") { + Some(stem) => stem, + None => continue, + }, + }; + let mut parts = stem.split('_'); + if let (Some(depot), Some(gid), None) = (parts.next(), parts.next(), parts.next()) { + if let (Ok(depot_id), Ok(manifest_id)) = (depot.parse::(), gid.parse::()) { + builds.insert((depot_id, manifest_id)); + } + } + } + builds +} + +fn load_manifest( + config_dir: &Path, + depot_id: u32, + manifest_id: u64, + depot_key: &[u8], +) -> Option { + let path = config_dir.join(format!("{depot_id}_{manifest_id}.manifest")); + let raw = fs::read(path).ok()?; + if raw.is_empty() { + return None; + } + let mut manifest = ContentManifest::parse(&raw)?; + manifest.decrypt_filenames(depot_key).then_some(manifest) +} + +/// Sidecar first (key-independent), then the cached manifest when this +/// operation holds the depot key. +fn load_file_entries( + config_dir: &Path, + depot_id: u32, + manifest_id: u64, + depot_key: Option<&[u8]>, +) -> Option> { + if let Some(entries) = read_filelist_sidecar(config_dir, depot_id, manifest_id) { + return Some(entries); + } + let manifest = load_manifest(config_dir, depot_id, manifest_id, depot_key?)?; + Some( + manifest + .files + .iter() + .map(|file| FileEntry { + name: file.filename.clone(), + is_dir: (file.flags & DEPOT_FILE_FLAG_DIRECTORY) != 0, + }) + .collect(), + ) +} + +/// Canonical comparison key: separators are already '/' after +/// decrypt_filenames; "."/empty components are dropped (the writer accepts +/// "./a" and "a//b" spellings) and case is folded so both sides of the +/// old-minus-current diff normalize identically. +fn normalized_key(rel: &str) -> String { + let mut key = String::with_capacity(rel.len()); + for part in rel.split('/') { + if part.is_empty() || part == "." { + continue; + } + if !key.is_empty() { + key.push('/'); + } + for byte in part.bytes() { + key.push(byte.to_ascii_lowercase() as char); + } + } + key +} + +/// Path components safe to delete under the install dir: plain relative +/// paths only; "."/empty components are dropped to mirror normalized_key. +fn sanitized_components(rel: &str) -> Option> { + if rel.starts_with('/') || rel.contains(':') { + return None; + } + let mut parts = Vec::new(); + for part in rel.split('/') { + if part.is_empty() || part == "." { + continue; + } + if part == ".." || part.bytes().any(|b| b.is_ascii_control()) { + return None; + } + parts.push(part); + } + if parts.is_empty() || parts[0].eq_ignore_ascii_case(".DepotDownloader") { + return None; + } + Some(parts) +} + +fn delete_stale_file(path: &Path) -> bool { + let Ok(meta) = fs::symlink_metadata(path) else { + return false; + }; + if meta.is_dir() { + // Manifest called it a file but the disk has a directory — leave it. + return false; + } + fs::remove_file(path).is_ok() +} + +fn sibling_original_backup(path: &Path) -> PathBuf { + let mut name = path.as_os_str().to_os_string(); + name.push(".original.exe"); + PathBuf::from(name) +} + +/// Manifest-created symlinks may target arbitrary paths; deleting through one +/// would escape the install dir, so candidates behind a symlinked directory +/// are left alone. +fn has_symlinked_ancestor(install_root: &Path, parts: &[&str]) -> bool { + let mut current = install_root.to_path_buf(); + for part in &parts[..parts.len().saturating_sub(1)] { + current.push(part); + let is_symlink = fs::symlink_metadata(¤t) + .map(|meta| meta.file_type().is_symlink()) + .unwrap_or(false); + if is_symlink { + return true; + } + } + false +} + +fn prune_empty_dirs_up(install_root: &Path, start: &Path) { + let mut current = start; + while current != install_root && current.starts_with(install_root) { + if fs::remove_dir(current).is_err() { + break; + } + let Some(parent) = current.parent() else { + break; + }; + current = parent; + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::content_manifest::{END_OF_MANIFEST_MAGIC, METADATA_MAGIC, PAYLOAD_MAGIC}; + use crate::depot_writer::DEPOT_FILE_FLAG_EXECUTABLE; + use crate::proto_wire::Writer; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "wnsteam_cleanup_{name}_{}", + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = fs::remove_dir_all(&dir); + fs::create_dir_all(&dir).unwrap(); + dir + } + + fn raw_manifest(depot_id: u32, manifest_id: u64, files: &[(&str, u32)]) -> Vec { + let mut payload = Vec::new(); + for (filename, flags) in files { + let mut file_body = Vec::new(); + { + let mut writer = Writer::new(&mut file_body); + writer.string_field(1, filename); + writer.uint64_field(2, 1); + writer.uint32_field(3, *flags); + } + Writer::new(&mut payload).submessage_field(1, &file_body); + } + + let mut metadata = Vec::new(); + { + let mut writer = Writer::new(&mut metadata); + writer.uint32_field(1, depot_id); + writer.uint64_field(2, manifest_id); + writer.bool_field_force(4, false); + } + + let mut raw = Vec::new(); + push_section(&mut raw, PAYLOAD_MAGIC, &payload); + push_section(&mut raw, METADATA_MAGIC, &metadata); + raw.extend_from_slice(&END_OF_MANIFEST_MAGIC.to_le_bytes()); + raw + } + + fn push_section(out: &mut Vec, magic: u32, body: &[u8]) { + out.extend_from_slice(&magic.to_le_bytes()); + out.extend_from_slice(&(body.len() as u32).to_le_bytes()); + out.extend_from_slice(body); + } + + fn write_manifest(config_dir: &Path, depot_id: u32, manifest_id: u64, files: &[(&str, u32)]) { + fs::write( + config_dir.join(format!("{depot_id}_{manifest_id}.manifest")), + raw_manifest(depot_id, manifest_id, files), + ) + .unwrap(); + } + + fn write_sidecar(config_dir: &Path, depot_id: u32, manifest_id: u64, files: &[(&str, u32)]) { + let manifest = ContentManifest::parse(&raw_manifest(depot_id, manifest_id, files)).unwrap(); + assert!(write_filelist_sidecar( + config_dir, + depot_id, + manifest_id, + &manifest + )); + } + + fn touch(install: &Path, rel: &str) { + let path = install.join(rel); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(path, b"x").unwrap(); + } + + fn spec(depot_id: u32, manifest_id: u64) -> ResolvedDepotSpec { + ResolvedDepotSpec { + depot_id, + manifest_id, + depot_key: vec![1u8; 32], + manifest_request_code: 0, + } + } + + fn config_dir(install: &Path) -> PathBuf { + let dir = install.join(".DepotDownloader"); + fs::create_dir_all(&dir).unwrap(); + dir + } + + fn install_current(config_dir: &Path, depot_id: u32, manifest_id: u64) { + let mut cfg = DepotConfigStore::load(config_dir); + cfg.finish_depot(depot_id, manifest_id); + } + + #[test] + fn marker_recording_skips_noop_transitions() { + let dir = temp_dir("marker_noop"); + assert!(!record_pending_cleanup(&dir, 100, 0, 555)); + assert!(!record_pending_cleanup(&dir, 100, INVALID_MANIFEST_ID, 555)); + assert!(!record_pending_cleanup(&dir, 100, 555, 555)); + assert!(pending_cleanup_markers(&dir).is_empty()); + + assert!(record_pending_cleanup(&dir, 100, 444, 555)); + assert_eq!(pending_cleanup_markers(&dir), vec![(100, 444)]); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn filelist_sidecar_roundtrips_files_and_dirs() { + let dir = temp_dir("sidecar_roundtrip"); + write_sidecar( + &dir, + 100, + 555, + &[("bin", DEPOT_FILE_FLAG_DIRECTORY), ("bin/game.exe", 0)], + ); + let entries = read_filelist_sidecar(&dir, 100, 555).unwrap(); + assert_eq!( + entries, + vec![ + FileEntry { + name: "bin".into(), + is_dir: true + }, + FileEntry { + name: "bin/game.exe".into(), + is_dir: false + }, + ] + ); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn deletes_files_dropped_by_new_manifest_and_prunes_dirs() { + let install = temp_dir("branch_switch"); + let config = config_dir(&install); + write_manifest( + &config, + 100, + 444, + &[ + ("bin", DEPOT_FILE_FLAG_DIRECTORY), + ("bin/old", DEPOT_FILE_FLAG_DIRECTORY), + ("bin/old/legacy.dll", 0), + ("bin/game.exe", DEPOT_FILE_FLAG_EXECUTABLE), + ("data.pak", 0), + ], + ); + write_manifest( + &config, + 100, + 555, + &[ + ("bin", DEPOT_FILE_FLAG_DIRECTORY), + ("bin/game.exe", DEPOT_FILE_FLAG_EXECUTABLE), + ("data.pak", 0), + ], + ); + install_current(&config, 100, 555); + touch(&install, "bin/old/legacy.dll"); + touch(&install, "bin/game.exe"); + touch(&install, "data.pak"); + touch(&install, "steam_settings/configs.app.ini"); + assert!(record_pending_cleanup(&config, 100, 444, 555)); + + let deleted = run_stale_file_cleanup(install.to_str().unwrap(), &config, &[spec(100, 555)]); + + assert_eq!(deleted, 1); + assert!(!install.join("bin/old/legacy.dll").exists()); + assert!(!install.join("bin/old").exists()); + assert!(install.join("bin/game.exe").exists()); + assert!(install.join("data.pak").exists()); + assert!(install.join("steam_settings/configs.app.ini").exists()); + assert!(install.exists()); + assert!(pending_cleanup_markers(&config).is_empty()); + let _ = fs::remove_dir_all(&install); + } + + #[test] + fn narrowed_update_cleans_via_sidecars_without_other_depots_keys() { + // A branch-switch update op carries only the changed depot; the other + // installed depot is represented by its sidecar alone. + let install = temp_dir("narrowed_update"); + let config = config_dir(&install); + write_sidecar(&config, 100, 444, &[("shared.dat", 0), ("only_old.dat", 0)]); + write_sidecar(&config, 100, 555, &[("core.dat", 0)]); + write_sidecar(&config, 200, 777, &[("Shared.dat", 0)]); + install_current(&config, 100, 555); + install_current(&config, 200, 777); + touch(&install, "shared.dat"); + touch(&install, "only_old.dat"); + touch(&install, "core.dat"); + assert!(record_pending_cleanup(&config, 100, 444, 555)); + + let deleted = run_stale_file_cleanup(install.to_str().unwrap(), &config, &[spec(100, 555)]); + + assert_eq!(deleted, 1); + assert!( + install.join("shared.dat").exists(), + "depot 200 still ships it" + ); + assert!(!install.join("only_old.dat").exists()); + assert!(install.join("core.dat").exists()); + assert!(pending_cleanup_markers(&config).is_empty()); + let _ = fs::remove_dir_all(&install); + } + + #[test] + fn unreadable_old_list_with_data_on_disk_defers_marker() { + let install = temp_dir("defer_unreadable_old"); + let config = config_dir(&install); + write_manifest(&config, 100, 444, &[("only_old.dat", 0)]); + write_sidecar(&config, 100, 555, &[("core.dat", 0)]); + install_current(&config, 100, 555); + touch(&install, "only_old.dat"); + assert!(record_pending_cleanup(&config, 100, 444, 555)); + + // Old manifest exists but this op has no key for depot 100 (and no + // old sidecar) → defer, keep the marker for an op that has the key. + let deleted = run_stale_file_cleanup(install.to_str().unwrap(), &config, &[spec(200, 777)]); + assert_eq!(deleted, 0); + assert!(install.join("only_old.dat").exists()); + assert_eq!(pending_cleanup_markers(&config), vec![(100, 444)]); + + // Once the data is gone entirely the marker can never act → dropped. + fs::remove_file(config.join("100_444.manifest")).unwrap(); + let deleted = run_stale_file_cleanup(install.to_str().unwrap(), &config, &[spec(200, 777)]); + assert_eq!(deleted, 0); + assert!(pending_cleanup_markers(&config).is_empty()); + assert!(install.join("only_old.dat").exists()); + let _ = fs::remove_dir_all(&install); + } + + #[test] + fn unreadable_installed_depot_defers_whole_pass() { + // An installed depot whose file list can't be read (no sidecar, no key + // in this op) leaves the keep-union incomplete, so the pass defers + // rather than risk deleting a live cross-depot file. + let install = temp_dir("unreadable_defers"); + let config = config_dir(&install); + write_sidecar(&config, 100, 444, &[("only_old.dat", 0)]); + write_sidecar(&config, 100, 555, &[("core.dat", 0)]); + install_current(&config, 100, 555); + install_current(&config, 200, 777); // no sidecar, no key in op + touch(&install, "only_old.dat"); + touch(&install, "core.dat"); + assert!(record_pending_cleanup(&config, 100, 444, 555)); + + let deleted = run_stale_file_cleanup(install.to_str().unwrap(), &config, &[spec(100, 555)]); + + assert_eq!(deleted, 0); + assert!(install.join("only_old.dat").exists()); + assert!(install.join("core.dat").exists()); + assert_eq!(pending_cleanup_markers(&config), vec![(100, 444)]); + let _ = fs::remove_dir_all(&install); + } + + #[test] + fn in_progress_depot_defers_whole_pass() { + // An in-flight (INVALID) depot's target build is unknown to cleanup, so + // a file moving into it can't be proven safe — the whole pass defers + // until that depot finishes. + let install = temp_dir("in_progress_defer"); + let config = config_dir(&install); + write_sidecar(&config, 100, 444, &[("only_old.dat", 0)]); + write_sidecar(&config, 100, 555, &[("core.dat", 0)]); + write_sidecar(&config, 200, 777, &[("dlc.dat", 0)]); + install_current(&config, 100, 555); + let mut cfg = DepotConfigStore::load(&config); + cfg.begin_depot(200); // 200 -> INVALID (mid-download) + touch(&install, "only_old.dat"); + touch(&install, "core.dat"); + touch(&install, "dlc.dat"); + assert!(record_pending_cleanup(&config, 100, 444, 555)); + + let deleted = run_stale_file_cleanup(install.to_str().unwrap(), &config, &[spec(100, 555)]); + + assert_eq!(deleted, 0, "deferred while depot 200 is in flight"); + assert!(install.join("only_old.dat").exists()); + assert!(install.join("dlc.dat").exists()); + assert_eq!(pending_cleanup_markers(&config), vec![(100, 444)]); + let _ = fs::remove_dir_all(&install); + } + + #[test] + fn cross_depot_file_moving_into_in_flight_depot_survives() { + // Regression: shared.dat was in depot 100's old build 444 and is not in + // its current build 555, but the in-flight depot 200 ships it. Cleanup + // can't see 200's target build, so it must not delete shared.dat. + let install = temp_dir("cross_depot_in_flight"); + let config = config_dir(&install); + write_sidecar(&config, 100, 444, &[("shared.dat", 0), ("only_old.dat", 0)]); + write_sidecar(&config, 100, 555, &[("core.dat", 0)]); + install_current(&config, 100, 555); + let mut cfg = DepotConfigStore::load(&config); + cfg.begin_depot(200); // 200 mid-download (will ship shared.dat), unreadable + touch(&install, "shared.dat"); + touch(&install, "core.dat"); + touch(&install, "only_old.dat"); + assert!(record_pending_cleanup(&config, 100, 444, 555)); + + let deleted = run_stale_file_cleanup(install.to_str().unwrap(), &config, &[spec(100, 555)]); + + assert_eq!(deleted, 0); + assert!( + install.join("shared.dat").exists(), + "live file shipped by the in-flight depot must survive" + ); + let _ = fs::remove_dir_all(&install); + } + + #[test] + fn sweeps_cached_old_build_without_a_marker() { + // The defining B behavior: an aborted build's files are reclaimed from + // its cached sidecar even when no .stalecleanup marker exists. + let install = temp_dir("sweep_no_marker"); + let config = config_dir(&install); + write_sidecar(&config, 100, 555, &[("core.dat", 0)]); + write_sidecar(&config, 100, 777, &[("core.dat", 0), ("beta_only.dll", 0)]); + install_current(&config, 100, 555); + touch(&install, "core.dat"); + touch(&install, "beta_only.dll"); // left by an aborted switch to 777 + touch(&install, "user_mod.cfg"); // user-added, in no manifest + // No marker recorded for 777. + assert!(pending_cleanup_markers(&config).is_empty()); + + let deleted = run_stale_file_cleanup(install.to_str().unwrap(), &config, &[spec(100, 555)]); + + assert_eq!(deleted, 1); + assert!( + !install.join("beta_only.dll").exists(), + "aborted build's file swept" + ); + assert!(install.join("core.dat").exists()); + assert!( + install.join("user_mod.cfg").exists(), + "user-added mod preserved" + ); + let _ = fs::remove_dir_all(&install); + } + + #[test] + fn normalization_matches_dotted_and_doubled_separator_spellings() { + assert_eq!(normalized_key("./Bin//Game.EXE"), "bin/game.exe"); + assert_eq!(normalized_key("bin/game.exe"), "bin/game.exe"); + + // Old spelled plainly, new spelled with "./" — still the same file. + let install = temp_dir("normalized_keep"); + let config = config_dir(&install); + write_sidecar(&config, 100, 444, &[("bin/x.dll", 0), ("only_old.dat", 0)]); + write_sidecar(&config, 100, 555, &[("./bin//x.dll", 0)]); + install_current(&config, 100, 555); + touch(&install, "bin/x.dll"); + touch(&install, "only_old.dat"); + assert!(record_pending_cleanup(&config, 100, 444, 555)); + + let deleted = run_stale_file_cleanup(install.to_str().unwrap(), &config, &[spec(100, 555)]); + assert_eq!(deleted, 1); + assert!(install.join("bin/x.dll").exists()); + assert!(!install.join("only_old.dat").exists()); + let _ = fs::remove_dir_all(&install); + } + + #[test] + fn rejects_unsafe_manifest_paths() { + assert_eq!( + sanitized_components("bin/game.exe"), + Some(vec!["bin", "game.exe"]) + ); + assert_eq!( + sanitized_components("./bin//game.exe"), + Some(vec!["bin", "game.exe"]) + ); + assert_eq!(sanitized_components(""), None); + assert_eq!(sanitized_components("."), None); + assert_eq!(sanitized_components("/etc/passwd"), None); + assert_eq!(sanitized_components("../outside.dat"), None); + assert_eq!(sanitized_components("bin/../../outside.dat"), None); + assert_eq!(sanitized_components("c:/windows/system32"), None); + assert_eq!(sanitized_components(".DepotDownloader/depot.config"), None); + assert_eq!(sanitized_components(".depotdownloader/depot.config"), None); + assert_eq!(sanitized_components("bad\nname"), None); + } + + #[test] + fn deletes_steamless_backup_only_for_unkept_exe_primaries() { + let install = temp_dir("steamless_backup"); + let config = config_dir(&install); + write_manifest( + &config, + 100, + 444, + &[("old.exe", 0), ("game.exe", 0), ("data.pak", 0)], + ); + write_manifest(&config, 100, 555, &[("game.exe", 0)]); + install_current(&config, 100, 555); + touch(&install, "old.exe"); + touch(&install, "old.exe.original.exe"); + touch(&install, "game.exe"); + touch(&install, "game.exe.original.exe"); + touch(&install, "data.pak"); + touch(&install, "data.pak.original.exe"); // not an exe primary + assert!(record_pending_cleanup(&config, 100, 444, 555)); + + let deleted = run_stale_file_cleanup(install.to_str().unwrap(), &config, &[spec(100, 555)]); + + assert_eq!(deleted, 3); // old.exe + its backup + data.pak + assert!(!install.join("old.exe").exists()); + assert!(!install.join("old.exe.original.exe").exists()); + assert!(install.join("game.exe").exists()); + assert!(install.join("game.exe.original.exe").exists()); + assert!(!install.join("data.pak").exists()); + assert!( + install.join("data.pak.original.exe").exists(), + "backup deletion is exe-only" + ); + let _ = fs::remove_dir_all(&install); + } + + #[test] + fn keeps_backup_shipped_by_current_manifest() { + let install = temp_dir("kept_backup"); + let config = config_dir(&install); + write_manifest(&config, 100, 444, &[("tool.exe", 0)]); + write_manifest( + &config, + 100, + 555, + &[("tool.exe.original.exe", 0), ("core.dat", 0)], + ); + install_current(&config, 100, 555); + touch(&install, "tool.exe"); + touch(&install, "tool.exe.original.exe"); + assert!(record_pending_cleanup(&config, 100, 444, 555)); + + let deleted = run_stale_file_cleanup(install.to_str().unwrap(), &config, &[spec(100, 555)]); + + assert_eq!(deleted, 1); + assert!(!install.join("tool.exe").exists()); + assert!( + install.join("tool.exe.original.exe").exists(), + "current manifest ships this exact name" + ); + let _ = fs::remove_dir_all(&install); + } + + #[cfg(unix)] + #[test] + fn skips_candidates_behind_symlinked_directories() { + let install = temp_dir("symlink_ancestor"); + let config = config_dir(&install); + let outside = temp_dir("symlink_target"); + fs::write(outside.join("precious.dat"), b"keep").unwrap(); + std::os::unix::fs::symlink(&outside, install.join("link")).unwrap(); + + write_manifest(&config, 100, 444, &[("link/precious.dat", 0)]); + write_manifest(&config, 100, 555, &[("core.dat", 0)]); + install_current(&config, 100, 555); + assert!(record_pending_cleanup(&config, 100, 444, 555)); + + let deleted = run_stale_file_cleanup(install.to_str().unwrap(), &config, &[spec(100, 555)]); + + assert_eq!(deleted, 0); + assert!(outside.join("precious.dat").exists()); + assert!(install.join("link").exists()); + let _ = fs::remove_dir_all(&install); + let _ = fs::remove_dir_all(&outside); + } + + #[test] + fn aborted_build_marker_reclaims_partial_files_after_revert() { + let install = temp_dir("aborted_revert"); + let config = config_dir(&install); + // Branch A (555) committed; switch to B (777) was cancelled mid-write + // after the sidecar was written and one B-only file landed on disk. + write_sidecar(&config, 100, 555, &[("game.exe", 0), ("data.pak", 0)]); + write_sidecar(&config, 100, 777, &[("game.exe", 0), ("beta_only.dll", 0)]); + install_current(&config, 100, 555); + touch(&install, "game.exe"); + touch(&install, "data.pak"); + touch(&install, "beta_only.dll"); + assert!(record_aborted_build(&config, 100, 777)); + assert!(!record_aborted_build(&config, 100, 0)); + assert!(!record_aborted_build(&config, 100, INVALID_MANIFEST_ID)); + + // The revert-verify to A completed → cleanup runs. + let deleted = run_stale_file_cleanup(install.to_str().unwrap(), &config, &[spec(100, 555)]); + + assert_eq!(deleted, 1); + assert!(!install.join("beta_only.dll").exists()); + assert!(install.join("game.exe").exists()); + assert!(install.join("data.pak").exists()); + assert!(pending_cleanup_markers(&config).is_empty()); + let _ = fs::remove_dir_all(&install); + } + + #[test] + fn aborted_build_marker_dropped_when_build_later_commits() { + let install = temp_dir("aborted_resumed"); + let config = config_dir(&install); + write_sidecar(&config, 100, 777, &[("game.exe", 0), ("beta_only.dll", 0)]); + install_current(&config, 100, 777); // resume finished the switch + touch(&install, "game.exe"); + touch(&install, "beta_only.dll"); + assert!(record_aborted_build(&config, 100, 777)); + + let deleted = run_stale_file_cleanup(install.to_str().unwrap(), &config, &[spec(100, 777)]); + + assert_eq!(deleted, 0); + assert!(install.join("beta_only.dll").exists()); + assert!(pending_cleanup_markers(&config).is_empty()); + let _ = fs::remove_dir_all(&install); + } + + #[test] + fn marker_for_current_manifest_is_dropped_without_deletions() { + let install = temp_dir("marker_current"); + let config = config_dir(&install); + write_manifest(&config, 100, 555, &[("core.dat", 0)]); + install_current(&config, 100, 555); + touch(&install, "core.dat"); + fs::write(stale_cleanup_marker_path(&config, 100, 555), "555").unwrap(); + + let deleted = run_stale_file_cleanup(install.to_str().unwrap(), &config, &[spec(100, 555)]); + + assert_eq!(deleted, 0); + assert!(install.join("core.dat").exists()); + assert!(pending_cleanup_markers(&config).is_empty()); + let _ = fs::remove_dir_all(&install); + } +} diff --git a/app/src/main/cpp/wn-steam-client/rust/src/depot_config.rs b/app/src/main/cpp/wn-steam-client/rust/src/depot_config.rs index 176de60b3..37fdf08a7 100644 --- a/app/src/main/cpp/wn-steam-client/rust/src/depot_config.rs +++ b/app/src/main/cpp/wn-steam-client/rust/src/depot_config.rs @@ -53,6 +53,13 @@ impl DepotConfigStore { self.installed.get(&depot_id).copied().unwrap_or(0) } + pub fn installed_entries(&self) -> Vec<(u32, u64)> { + self.installed + .iter() + .map(|(depot, manifest)| (*depot, *manifest)) + .collect() + } + pub fn is_installed(&self, depot_id: u32, manifest_id: u64) -> bool { self.installed .get(&depot_id) @@ -74,6 +81,21 @@ impl DepotConfigStore { let _ = fs::remove_file(self.config_path()); } + /// Removes only the given depots' entries, unlike [`Self::discard`] which + /// wipes the whole store: a fresh re-download of one batch must not hide + /// other batches' installed depots from depot.config consumers (the + /// stale-file keep-union, update checks). + pub fn discard_depots(&mut self, depot_ids: impl IntoIterator) -> bool { + let mut changed = false; + for depot_id in depot_ids { + changed |= self.installed.remove(&depot_id).is_some(); + } + if !changed { + return true; + } + self.save() + } + fn config_path(&self) -> PathBuf { self.config_dir.join("depot.config") } @@ -201,7 +223,7 @@ fn get_u32(buf: &[u8]) -> Option { Some(u32::from_le_bytes(buf.get(..4)?.try_into().ok()?)) } -fn atomic_write_synced(final_path: &Path, bytes: &[u8]) -> bool { +pub(crate) fn atomic_write_synced(final_path: &Path, bytes: &[u8]) -> bool { let Some(parent) = final_path.parent() else { return false; }; @@ -257,6 +279,24 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + #[test] + fn discard_depots_leaves_other_entries_intact() { + let dir = temp_dir("discard_depots_scoped"); + let mut store = DepotConfigStore::load(&dir); + assert!(store.finish_depot(100, 555)); + assert!(store.finish_depot(200, 777)); + + assert!(store.discard_depots([200, 999])); + assert!(store.is_installed(100, 555)); + assert_eq!(store.installed_manifest(200), 0); + + let loaded = DepotConfigStore::load(&dir); + assert!(loaded.is_installed(100, 555)); + assert_eq!(loaded.installed_manifest(200), 0); + assert_eq!(loaded.installed_entries(), vec![(100, 555)]); + let _ = fs::remove_dir_all(&dir); + } + #[test] fn progress_sidecar_roundtrips_sorted_indices() { let dir = temp_dir("progress_sidecar_roundtrips"); diff --git a/app/src/main/cpp/wn-steam-client/rust/src/depot_downloader.rs b/app/src/main/cpp/wn-steam-client/rust/src/depot_downloader.rs index 1c6751d85..551c1d8ee 100644 --- a/app/src/main/cpp/wn-steam-client/rust/src/depot_downloader.rs +++ b/app/src/main/cpp/wn-steam-client/rust/src/depot_downloader.rs @@ -1,5 +1,9 @@ use crate::cdn_client::{CdnClient, CdnManifestResult}; use crate::content_manifest::ContentManifest; +use crate::depot_cleanup::{ + backfill_filelist_sidecar, record_aborted_build, record_pending_cleanup, + run_stale_file_cleanup, write_filelist_sidecar, +}; use crate::depot_config::{DepotConfigStore, DepotProgressStore, INVALID_MANIFEST_ID}; use crate::depot_writer::{write_depot_sequential, DepotWriteOptions}; use crate::pb::ccontentserverdirectory::CContentServerDirectoryServerInfo; @@ -316,7 +320,18 @@ pub fn download_resolved_depots_with_cancel_progress( let mut cfg = DepotConfigStore::load(&config_dir); if fresh { - cfg.discard(); + for depot in depots { + record_pending_cleanup( + &config_dir, + depot.depot_id, + cfg.installed_manifest(depot.depot_id), + depot.manifest_id, + ); + } + // Per-depot, not discard(): wiping the whole store would hide other + // batches' depots from depot.config consumers (stale-file keep-union, + // update checks) for the rest of this app's install. + cfg.discard_depots(depots.iter().map(|depot| depot.depot_id)); for depot in depots { DepotProgressStore::remove(&config_dir, depot.depot_id, depot.manifest_id); remove_clean_pause_marker(&config_dir, depot.depot_id, depot.manifest_id); @@ -341,6 +356,9 @@ pub fn download_resolved_depots_with_cancel_progress( let clean_pause = has_clean_pause_marker(&config_dir, depot.depot_id, depot.manifest_id); if decide_depot_resume(fresh, &cfg, spec, clean_pause) == DepotResumeDecision::SkipInstalled { + // Installs that predate filelist sidecars get one written now so + // the stale-file keep-union can read this depot without its key. + backfill_filelist_sidecar(&config_dir, depot); result.depots_skipped += 1; continue; } @@ -350,6 +368,12 @@ pub fn download_resolved_depots_with_cancel_progress( depot.depot_id )); } + record_pending_cleanup( + &config_dir, + depot.depot_id, + cfg.installed_manifest(depot.depot_id), + depot.manifest_id, + ); if !cfg.begin_depot(depot.depot_id) { return DepotDownloadResult::fail(format!( "download: depot.config begin failed for depot {}", @@ -396,24 +420,20 @@ pub fn download_resolved_depots_with_cancel_progress( depot.depot_id )); } + // Before any game file is touched, so an aborted write still leaves a + // key-independent record of what this build may have put on disk. + let _ = write_filelist_sidecar(&config_dir, depot.depot_id, depot.manifest_id, &manifest); let depot_id = depot.depot_id; let depots_done = depot_index as u32; let chunk_progress = |done: u64, total: u64, verifying: bool| { if let Some(on_progress) = on_progress { - let progress = map_write_progress( - depot_id, - depots_done, - depots_total, - done, - total, - verifying, - ); + let progress = + map_write_progress(depot_id, depots_done, depots_total, done, total, verifying); on_progress(&progress); } }; - let chunk_progress: crate::depot_writer::DepotChunkProgressCallback = - &chunk_progress; + let chunk_progress: crate::depot_writer::DepotChunkProgressCallback = &chunk_progress; let write_result = write_depot_sequential( &manifest, &depot.depot_key, @@ -431,6 +451,10 @@ pub fn download_resolved_depots_with_cancel_progress( if write_result.resume_trust_safe { let _ = write_clean_pause_marker(&config_dir, depot.depot_id, depot.manifest_id); } + // The write may have left this build's files on disk without ever + // committing a gid to diff them against — mark the target so the + // next successful download reclaims its orphans. + record_aborted_build(&config_dir, depot.depot_id, depot.manifest_id); return DepotDownloadResult::fail(format!( "download: depot {} write failed: {}", depot.depot_id, write_result.error @@ -438,6 +462,7 @@ pub fn download_resolved_depots_with_cancel_progress( } if !cfg.finish_depot(depot.depot_id, depot.manifest_id) { + record_aborted_build(&config_dir, depot.depot_id, depot.manifest_id); return DepotDownloadResult::fail(format!( "download: depot.config finish failed for depot {}", depot.depot_id @@ -449,6 +474,9 @@ pub fn download_resolved_depots_with_cancel_progress( result.depots_completed += 1; } + if result.success { + run_stale_file_cleanup(install_dir, &config_dir, depots); + } result } @@ -664,6 +692,209 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + #[test] + fn per_batch_fresh_download_keeps_other_batches_config_entries() { + // Verify Files runs one fresh native call per app batch (base, then + // each DLC); the second batch's reset must not wipe the first's + // depot.config entries or the stale-file keep-union goes blind. + let dir = temp_dir("fresh_batches_keep_config"); + let config_dir = dir.join(".DepotDownloader"); + fs::create_dir_all(&config_dir).unwrap(); + let server = [CContentServerDirectoryServerInfo { + host: "cdn.example".into(), + https_support: "mandatory".into(), + ..Default::default() + }]; + let spec = |depot_id, manifest_id| ResolvedDepotSpec { + depot_id, + manifest_id, + depot_key: vec![1u8; 32], + manifest_request_code: 0, + }; + fs::write( + config_dir.join("100_555.manifest"), + raw_layout_manifest(100, 555, "base.bin", 5), + ) + .unwrap(); + fs::write( + config_dir.join("200_777.manifest"), + raw_layout_manifest(200, 777, "dlc.bin", 5), + ) + .unwrap(); + + let base = download_resolved_depots( + dir.to_str().unwrap(), + &[spec(100, 555)], + &server, + "", + true, + 4, + ); + assert!(base.success, "{}", base.error); + let dlc = download_resolved_depots( + dir.to_str().unwrap(), + &[spec(200, 777)], + &server, + "", + true, + 4, + ); + assert!(dlc.success, "{}", dlc.error); + + let cfg = DepotConfigStore::load(&config_dir); + assert!(cfg.is_installed(100, 555), "batch 2 must not wipe batch 1"); + assert!(cfg.is_installed(200, 777)); + assert!(config_dir.join("100_555.filelist").is_file()); + assert!(config_dir.join("200_777.filelist").is_file()); + let _ = fs::remove_dir_all(&dir); + } + + #[test] + fn failed_write_records_aborted_build_and_revert_reclaims_it() { + let dir = temp_dir("aborted_write_recovery"); + let config_dir = dir.join(".DepotDownloader"); + fs::create_dir_all(&config_dir).unwrap(); + let server = [CContentServerDirectoryServerInfo { + host: "cdn.example".into(), + https_support: "mandatory".into(), + ..Default::default() + }]; + let spec = |manifest_id| ResolvedDepotSpec { + depot_id: 100, + manifest_id, + depot_key: vec![1u8; 32], + manifest_request_code: 0, + }; + + // Branch A (555) installs cleanly (chunkless layout manifest). + fs::write( + config_dir.join("100_555.manifest"), + raw_layout_manifest(100, 555, "game.bin", 5), + ) + .unwrap(); + let result = + download_resolved_depots(dir.to_str().unwrap(), &[spec(555)], &server, "", false, 4); + assert!(result.success, "{}", result.error); + + // Branch B (777) needs a real chunk from the unreachable CDN → the + // write fails mid-switch, after the sidecar was persisted. + fs::write( + config_dir.join("100_777.manifest"), + raw_chunked_manifest(100, 777, "beta_only.bin"), + ) + .unwrap(); + let result = + download_resolved_depots(dir.to_str().unwrap(), &[spec(777)], &server, "", false, 4); + assert!(!result.success); + assert!(config_dir.join("100_777.filelist").is_file()); + // Two pending markers: 555 was recorded for the A→B transition (in + // case B committed), 777 records the aborted target itself. + assert_eq!( + crate::depot_cleanup::pending_cleanup_markers(&config_dir), + vec![(100, 555), (100, 777)] + ); + + // Simulate the partial write the aborted switch left behind, then the + // revert-to-A verify (fresh) completing successfully. + fs::write(dir.join("beta_only.bin"), b"part").unwrap(); + let result = + download_resolved_depots(dir.to_str().unwrap(), &[spec(555)], &server, "", true, 4); + assert!(result.success, "{}", result.error); + + assert!(dir.join("game.bin").exists()); + assert!( + !dir.join("beta_only.bin").exists(), + "aborted build's orphan must be reclaimed" + ); + assert!(crate::depot_cleanup::pending_cleanup_markers(&config_dir).is_empty()); + let _ = fs::remove_dir_all(&dir); + } + + fn raw_chunked_manifest(depot_id: u32, manifest_id: u64, filename: &str) -> Vec { + let mut chunk_body = Vec::new(); + { + let mut writer = Writer::new(&mut chunk_body); + writer.bytes_field(1, &[7u8; 20]); + writer.tag(2, crate::proto_wire::WireType::Fixed32); + writer.raw_bytes(&0x1234_5678u32.to_le_bytes()); + writer.uint64_field(3, 0); + writer.uint32_field(4, 4); + writer.uint32_field(5, 4); + } + let mut file_body = Vec::new(); + { + let mut writer = Writer::new(&mut file_body); + writer.string_field(1, filename); + writer.uint64_field(2, 4); + writer.submessage_field(6, &chunk_body); + } + let mut payload = Vec::new(); + Writer::new(&mut payload).submessage_field(1, &file_body); + + let mut metadata = Vec::new(); + { + let mut writer = Writer::new(&mut metadata); + writer.uint32_field(1, depot_id); + writer.uint64_field(2, manifest_id); + writer.bool_field_force(4, false); + } + + let mut raw = Vec::new(); + push_section(&mut raw, PAYLOAD_MAGIC, &payload); + push_section(&mut raw, METADATA_MAGIC, &metadata); + raw.extend_from_slice(&END_OF_MANIFEST_MAGIC.to_le_bytes()); + raw + } + + #[test] + fn branch_switch_deletes_files_absent_from_new_manifest() { + let dir = temp_dir("branch_switch_stale_files"); + let config_dir = dir.join(".DepotDownloader"); + fs::create_dir_all(&config_dir).unwrap(); + let server = [CContentServerDirectoryServerInfo { + host: "cdn.example".into(), + https_support: "mandatory".into(), + ..Default::default() + }]; + let spec = |manifest_id| ResolvedDepotSpec { + depot_id: 100, + manifest_id, + depot_key: vec![1u8; 32], + manifest_request_code: 0, + }; + + fs::write( + config_dir.join("100_555.manifest"), + raw_layout_manifest(100, 555, "beta_only.bin", 5), + ) + .unwrap(); + let result = + download_resolved_depots(dir.to_str().unwrap(), &[spec(555)], &server, "", false, 4); + assert!(result.success, "{}", result.error); + assert!(dir.join("beta_only.bin").exists()); + + // Files outside any manifest must survive the switch untouched. + fs::write(dir.join("user_notes.txt"), b"keep me").unwrap(); + + fs::write( + config_dir.join("100_777.manifest"), + raw_layout_manifest(100, 777, "public.bin", 5), + ) + .unwrap(); + let result = + download_resolved_depots(dir.to_str().unwrap(), &[spec(777)], &server, "", false, 4); + assert!(result.success, "{}", result.error); + + assert!(dir.join("public.bin").exists()); + assert!( + !dir.join("beta_only.bin").exists(), + "stale branch file must be deleted" + ); + assert!(dir.join("user_notes.txt").exists()); + assert!(crate::depot_cleanup::pending_cleanup_markers(&config_dir).is_empty()); + let _ = fs::remove_dir_all(&dir); + } + fn temp_dir(name: &str) -> PathBuf { let dir = std::env::temp_dir().join(format!( "wnsteam_downloader_{name}_{}", diff --git a/app/src/main/cpp/wn-steam-client/rust/src/jni.rs b/app/src/main/cpp/wn-steam-client/rust/src/jni.rs index 65969682b..dacf3996c 100644 --- a/app/src/main/cpp/wn-steam-client/rust/src/jni.rs +++ b/app/src/main/cpp/wn-steam-client/rust/src/jni.rs @@ -37,7 +37,7 @@ unsafe extern "C" { fn __android_log_write(prio: i32, tag: *const i8, text: *const i8) -> i32; } -fn android_log(tag: &str, message: &str) { +pub(crate) fn android_log(tag: &str, message: &str) { #[cfg(target_os = "android")] { let Ok(tag) = CString::new(tag) else { diff --git a/app/src/main/cpp/wn-steam-client/rust/src/lib.rs b/app/src/main/cpp/wn-steam-client/rust/src/lib.rs index 14a267153..266f83bcb 100644 --- a/app/src/main/cpp/wn-steam-client/rust/src/lib.rs +++ b/app/src/main/cpp/wn-steam-client/rust/src/lib.rs @@ -19,6 +19,7 @@ pub mod cmsg_protobuf_header; pub mod content_manifest; pub mod crypto; pub mod depot_chunk; +pub mod depot_cleanup; pub mod depot_config; pub mod depot_downloader; pub mod depot_writer; diff --git a/app/src/main/feature/stores/steam/data/DownloadInfo.kt b/app/src/main/feature/stores/steam/data/DownloadInfo.kt index 2330c0524..b454e0c14 100644 --- a/app/src/main/feature/stores/steam/data/DownloadInfo.kt +++ b/app/src/main/feature/stores/steam/data/DownloadInfo.kt @@ -35,6 +35,11 @@ class DownloadInfo( @Volatile var isDeleting: Boolean = false @Volatile var isCancelling: Boolean = false + + /** True when this is the restore verify that repairs the previous branch + * after a cancelled switch — drives the "Restoring" label and the + * cancel-warning dialog. */ + @Volatile var isRepair: Boolean = false private var downloadJob: Job? = null private val downloadProgressListeners = CopyOnWriteArrayList<((Float) -> Unit)>() private val progresses: Array = Array(jobCount) { 0f } diff --git a/app/src/main/feature/stores/steam/data/LaunchInfo.kt b/app/src/main/feature/stores/steam/data/LaunchInfo.kt index 75d747ae1..caf3ec823 100644 --- a/app/src/main/feature/stores/steam/data/LaunchInfo.kt +++ b/app/src/main/feature/stores/steam/data/LaunchInfo.kt @@ -11,6 +11,8 @@ data class LaunchInfo( val workingDir: String, val description: String, val type: String, + // Default keeps already-cached appinfo JSON (without this key) decodable. + val arguments: String = "", @Serializable(with = OsEnumSetSerializer::class) val configOS: java.util.EnumSet, val configArch: OSArch, diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index d2f6cefd3..a4834f2de 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -92,9 +92,10 @@ import com.winlator.cmod.runtime.display.environment.ImageFs import com.winlator.cmod.runtime.system.GPUInformation import com.winlator.cmod.runtime.system.SessionKeepAliveService import com.winlator.cmod.shared.android.AppTerminationHelper -import com.winlator.cmod.shared.ui.toast.WinToast import com.winlator.cmod.shared.android.NotificationHelper +import com.winlator.cmod.shared.io.FileUtils import com.winlator.cmod.shared.io.StorageUtils +import com.winlator.cmod.shared.ui.toast.WinToast import dagger.hilt.android.AndroidEntryPoint import com.winlator.cmod.feature.stores.steam.enums.EDepotFileFlag import com.winlator.cmod.feature.stores.steam.enums.ELicenseFlags @@ -2306,6 +2307,158 @@ class SteamService : Service() { return container.executablePath.ifEmpty { getInstalledExe(gameId) } } + private fun findSteamShortcut( + context: Context, + appId: Int, + ) = ContainerManager(context).loadShortcuts().find { + it.getExtra("game_source") == "STEAM" && it.getExtra("app_id") == appId.toString() + } + + /** + * Lightweight read of the Steam shortcut's `[Extra Data]` section straight + * from its .desktop file, with the owning container's root dir. Deliberately + * NOT findSteamShortcut/loadShortcuts on read-only paths: the Shortcut + * constructor decodes cover-art bitmaps, runs PE icon extraction and can + * rewrite .desktop files — far too heavy (and write-racy) for hot paths + * that only need a couple of strings. Returns null when no Steam shortcut + * exists for [appId]. + */ + private fun readSteamShortcutExtras( + context: Context, + appId: Int, + ): Pair, File>? { + val appIdStr = appId.toString() + val homeDir = File(ImageFs.find(context).rootDir, "home") + val containerDirs = + homeDir.listFiles { f -> f.isDirectory && f.name.startsWith("${ImageFs.USER}-") } + ?: return null + for (containerDir in containerDirs) { + val desktopDir = File(containerDir, ".wine/drive_c/users/${ImageFs.USER}/Desktop") + val files = desktopDir.listFiles { f -> f.name.endsWith(".desktop") } ?: continue + for (file in files) { + var section = "" + val extras = mutableMapOf() + // FileUtils.readLines (also used by Shortcut's parser) returns + // what it could read on IO errors — one corrupt .desktop file + // must not abort the scan of the remaining containers. + for (raw in FileUtils.readLines(file)) { + val line = raw.trim() + if (line.isEmpty() || line.startsWith("#")) continue + if (line.startsWith("[")) { + section = line.substringAfter("[").substringBefore("]") + continue + } + if (section != "Extra Data") continue + val key = line.substringBefore("=", "") + if (key.isNotEmpty()) extras[key] = line.substringAfter("=") + } + if (extras["game_source"] == "STEAM" && extras["app_id"] == appIdStr) { + return extras to containerDir + } + } + } + return null + } + + /** Reads executablePath from a container's `.container` config without Container construction. */ + private fun readContainerExecutablePath(containerDir: File): String = + runCatching { + val configFile = File(containerDir, ".container") + if (!configFile.isFile) return "" + JSONObject(configFile.readText()).optString("executablePath", "") + }.getOrElse { "" } + + /** + * Persists the user's launch-option choice (an appinfo `config.launch` entry) on + * the game's shortcut + container, matching resolveRelativeGameExe's priority: + * shortcut `launch_exe_path` first, container.executablePath as the synced + * fallback. The option's own arguments go to `launch_exe_args`, kept separate + * from the user-editable custom args (`execArgs`). Call on an IO dispatcher. + */ + fun setSelectedLaunchOption( + context: Context, + appId: Int, + executable: String, + arguments: String, + ): Boolean { + var shortcut = findSteamShortcut(context, appId) + if (shortcut == null) { + // Installs from older builds may predate shortcut creation on download. + createSteamShortcut(context, appId) + shortcut = findSteamShortcut(context, appId) + } + if (shortcut == null) { + Timber.w("setSelectedLaunchOption: no shortcut for appId=$appId") + return false + } + shortcut.putExtra("launch_exe_path", executable) + shortcut.putExtra("launch_exe_args", arguments.ifBlank { null }) + shortcut.saveData() + shortcut.container?.let { + it.executablePath = executable + it.saveData() + } + return true + } + + /** + * Currently effective launch option as (executable, arguments), resolved in the + * same order the launch path uses: shortcut `launch_exe_path` first, the owning + * container's executablePath as fallback, then the installed exe. Reads the + * .desktop/.container files directly (this runs on every game-detail open). + * Call on an IO dispatcher. + */ + fun getSelectedLaunchOption( + context: Context, + appId: Int, + ): Pair { + val found = runCatching { readSteamShortcutExtras(context, appId) }.getOrNull() + val extras = found?.first.orEmpty() + val exe = + extras["launch_exe_path"].orEmpty().ifBlank { + found?.second?.let { readContainerExecutablePath(it) }.orEmpty().ifBlank { + getInstalledExe(appId) + } + } + return exe.replace('\\', '/') to extras["launch_exe_args"].orEmpty() + } + + /** + * Persists the user's beta-branch choice on the game's shortcut. + * Pass a blank [branchName] to clear the selection (reverts to public). + * Call on an IO dispatcher. + */ + fun setSelectedBetaBranch( + context: Context, + appId: Int, + branchName: String, + recordPrevious: Boolean = true, + ): Boolean { + var shortcut = findSteamShortcut(context, appId) + if (shortcut == null) { + createSteamShortcut(context, appId) + shortcut = findSteamShortcut(context, appId) + } + if (shortcut == null) { + Timber.w("setSelectedBetaBranch: no shortcut for appId=$appId") + return false + } + // Remember the branch being replaced ("public" when blank) so a + // cancelled switch can restore the last committed selection. Only + // the first un-committed switch is recorded — re-picks before a + // download lands must not overwrite the true last-known-good. + if (recordPrevious) { + val current = shortcut.getExtra("selectedBranch").orEmpty().trim() + val previous = shortcut.getExtra("previousBranch").orEmpty().trim() + if (previous.isEmpty() && !current.equals(branchName.trim(), ignoreCase = true)) { + shortcut.putExtra("previousBranch", current.ifBlank { "public" }) + } + } + shortcut.putExtra("selectedBranch", branchName.ifBlank { null }) + shortcut.saveData() + return true + } + suspend fun deleteApp(appId: Int): Boolean = withContext(Dispatchers.IO) { val appDirPath = getAppDirPath(appId) @@ -2508,7 +2661,7 @@ class SteamService : Service() { targetDepotIds = targetDepotIds.toSet().takeIf { it.isNotEmpty() }, ) - fun downloadAppForVerify(appId: Int): DownloadInfo? = + fun downloadAppForVerify(appId: Int, isRestore: Boolean = false): DownloadInfo? = downloadApp( appId, resolveInstalledDlcIdsForUpdateOrVerify(appId), @@ -2516,7 +2669,7 @@ class SteamService : Service() { enableVerify = true, allowPersistedProgress = false, downloadTaskType = DownloadRecord.TASK_VERIFY, - ) + )?.also { it.isRepair = isRestore } private fun resolveInstalledDlcIdsForUpdateOrVerify(appId: Int): List { val dlcAppIds = getInstalledDlcDepotsOf(appId).orEmpty().toMutableList() @@ -2633,7 +2786,7 @@ class SteamService : Service() { appId = appId, downloadableDepots = downloadableDepots, userSelectedDlcAppIds = effectiveDlcAppIds, - branch = "public", + branch = resolveSelectedBetaName(appId).ifBlank { "public" }, includeInstalledDepots = includeInstalledDepots, enableVerify = enableVerify, allowPersistedProgress = allowPersistedProgress, @@ -4458,6 +4611,19 @@ class SteamService : Service() { runCatching { MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DRM_PATCHED) } runCatching { MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DRM_UNPACK_CHECKED) } + pruneStaleDepotManifestCache(appDirPath) + + // A committed download makes the current selection the new + // last-known-good — drop the cancelled-switch restore point. + instance?.let { svc -> + clearPreviousBetaBranch(svc, downloadInfo.gameId) + } + // Record the installed branch durably so a never-launched + // install (whose build may no longer match any branch's + // current PICS manifest) can still be recovered after a + // WinNative reinstall. Launch later rewrites this file in full. + persistInstalledBranchName(downloadInfo.gameId, appDirPath) + // Same reason as the runCatching above: a Room exception // here used to FAIL a fully-downloaded game with COMPLETE // marker already on disk. @@ -5354,7 +5520,7 @@ class SteamService : Service() { suspend fun prepareLibSteamClientForLaunch(appId: Int) { if (appId <= 0) return startOverlayPollLoop() - val selectedBranch = resolveSelectedBetaName(appId) + val selectedBranch = recoverSelectedBetaName(appId) val baseStatePrimed = runCatching { primeLibSteamClientLaunchState(appId, selectedBranch) } .getOrElse { e -> @@ -5443,16 +5609,130 @@ class SteamService : Service() { if (appId <= 0) return "" val svc = instance ?: return "" return runCatching { - for (sc in ContainerManager(svc).loadShortcuts()) { - val scAppId = sc.getExtra("app_id").toIntOrNull() ?: continue - if (scAppId != appId) continue - val branch = sc.getExtra("selectedBranch").trim() - if (branch.isNotEmpty()) return@runCatching branch - } - "" + readSteamShortcutExtras(svc, appId)?.first?.get("selectedBranch").orEmpty().trim() }.getOrElse { "" } } + /** + * Like [resolveSelectedBetaName], but when the shortcut record is gone + * (reinstalling WinNative wipes app-private shortcuts while game files + * on external storage survive) it recovers the branch the installed + * build is actually on and heals the shortcut. Steam persists the beta + * key with the install (appmanifest ACF UserConfig/betakey); our durable + * analogs in the surviving game dir are, in order of trust: + * 1. `.DepotDownloader/depot.config` — the installed manifest gids, + * matched exactly against each beta's current PICS manifests + * (evidence of what the FILES are); + * 2. `steam_settings/configs.app.ini` `branch_name` — the selection + * recorded at the game's last launch. + * Returns "" (public) when neither source identifies a beta — e.g. the + * installed build predates the current PICS manifests and the game was + * never launched. Call on an IO dispatcher. + */ + // Apps whose branch recovery already ran this process — recovery can only + // succeed right after a WinNative reinstall, so one failed attempt per + // process is enough (a success heals the shortcut and short-circuits + // every later call through the persisted fast path above). + private val betaRecoveryAttemptedApps = + java.util.Collections.synchronizedSet(mutableSetOf()) + + suspend fun recoverSelectedBetaName(appId: Int): String { + val persisted = resolveSelectedBetaName(appId) + if (persisted.isNotEmpty()) return persisted + if (!betaRecoveryAttemptedApps.add(appId)) return "" + val svc = instance ?: return "" + return runCatching { + if (!isAppInstalled(appId)) return@runCatching "" + val app = withContext(Dispatchers.IO) { svc.appDao.findApp(appId) } ?: return@runCatching "" + val betaNames = app.branches.keys.filterNot { it.equals("public", ignoreCase = true) } + if (betaNames.isEmpty()) return@runCatching "" + + val appDirPath = getAppDirPath(appId) + val inferred = + inferBranchFromInstalledManifests(app, betaNames, appDirPath) + ?: readBranchNameFromSettingsIni(app, appDirPath) + // Canonicalize to the PICS branches-map key: downstream + // buildId lookups (app.branches[name]) are exact-key. + ?.let { name -> betaNames.firstOrNull { it.equals(name, ignoreCase = true) } } + if (inferred.isNullOrEmpty()) return@runCatching "" + if (setSelectedBetaBranch(svc, appId, inferred, recordPrevious = false)) { + Timber.i("Recovered beta branch '$inferred' for appId=$appId from the installed game dir") + } + inferred + }.getOrElse { e -> + Timber.w(e, "Beta branch recovery failed for appId=$appId") + "" + } + } + + /** + * The beta whose current PICS manifests exactly match the installed + * manifest gids — every installed depot that declares a manifest for the + * branch must match, and at least one gid must differ from public (else + * the install is indistinguishable from public and stays public). + * Null when no or several betas qualify. + */ + private fun inferBranchFromInstalledManifests( + app: SteamApp, + betaNames: List, + appDirPath: String, + ): String? { + val installed = readInstalledDepotManifestIds(appDirPath) + if (installed.isEmpty()) return null + val matches = + betaNames.filter { branch -> + var distinctFromPublic = false + var comparedAny = false + for ((depotId, installedGid) in installed) { + val depot = app.depots[depotId] ?: continue + val branchGid = + (depot.manifests[branch] ?: depot.encryptedManifests[branch])?.gid ?: continue + comparedAny = true + if (branchGid != installedGid) return@filter false + val publicGid = + (depot.manifests["public"] ?: depot.encryptedManifests["public"])?.gid + if (publicGid != branchGid) distinctFromPublic = true + } + comparedAny && distinctFromPublic + } + return matches.singleOrNull() + } + + /** + * `branch_name` from the surviving `steam_settings/configs.app.ini` + * (written at every launch by writeCompleteSettingsDir). Checked at the + * game-dir root and next to the game exe — the two places the launch + * path writes settings dirs. + */ + private fun readBranchNameFromSettingsIni( + app: SteamApp, + appDirPath: String, + ): String? { + val exeDir = + app.config.launch + .firstOrNull { it.executable.endsWith(".exe") } + ?.executable + ?.replace('\\', '/') + ?.substringBeforeLast('/', "") + .orEmpty() + val candidates = + listOfNotNull( + File(appDirPath, "steam_settings/configs.app.ini"), + exeDir.takeIf { it.isNotEmpty() }?.let { File(appDirPath, "$it/steam_settings/configs.app.ini") }, + ) + for (ini in candidates) { + if (!ini.isFile) continue + val name = + FileUtils.readLines(ini) + .firstOrNull { it.startsWith("branch_name=") } + ?.substringAfter("=") + ?.trim() + .orEmpty() + if (name.isNotEmpty() && !name.equals("public", ignoreCase = true)) return name + } + return null + } + suspend fun refreshEncryptedAppTicketForLibSteamClient(appId: Int): Boolean { if (appId <= 0) return false val instance = SteamService.instance ?: return false @@ -7311,14 +7591,19 @@ class SteamService : Service() { suspend fun isUpdatePending( appId: Int, - branch: String = "public", + branch: String? = null, ): Boolean = checkForAppUpdate(appId, branch).hasUpdate suspend fun checkForAppUpdate( appId: Int, - branch: String = "public", + // null = the game's selected beta, recovering a selection lost to an + // app reinstall — otherwise a reinstalled beta install would be + // diffed against public and silently downgraded by the update. + requestedBranch: String? = null, ): SteamUpdateInfo = withContext(Dispatchers.IO) { + val branch = requestedBranch ?: recoverSelectedBetaName(appId).ifBlank { "public" } + fun SteamUpdateInfo.logged(): SteamUpdateInfo { Timber.i( "Steam update check result: appId=$appId branch=$branch " + @@ -7352,6 +7637,8 @@ class SteamService : Service() { } val installedManifestIds = readInstalledDepotManifestIds(appDirPath) + val hasDepotConfig = + File(File(appDirPath, ".DepotDownloader"), "depot.config").exists() val cachedManifestFiles: Set = File(appDirPath, ".DepotDownloader").list()?.toHashSet() ?: emptySet() // Resolve manifests once per depot; the filter below decides which need updating, @@ -7367,7 +7654,16 @@ class SteamService : Service() { val installedManifestId = installedManifestIds[depotId] if (installedManifestId != null) { installedManifestId != manifest.gid + } else if (hasDepotConfig) { + // depot.config exists but doesn't list this depot: it + // isn't cleanly installed (e.g. a cancelled switch left + // the entry dropped/invalid). Treat as needs-download + // rather than trusting a stale cached manifest, which + // would falsely report "no updates". + true } else { + // True legacy install with no depot.config — the cached + // manifest is the only installed-version signal. "${depotId}_${manifest.gid}.manifest" !in cachedManifestFiles } } @@ -7426,6 +7722,23 @@ class SteamService : Service() { return null } + /** + * Best-effort re-fetch + persist of one app's PICS appinfo. Used to heal + * cached rows that predate newly parsed fields (e.g. LaunchInfo.arguments). + * Returns false when offline / fetch fails; callers keep using the cached row. + */ + suspend fun refreshAppInfoFromPics(appId: Int): Boolean { + val fresh = + try { + fetchLatestSteamAppInfo(appId) + } catch (e: Exception) { + Timber.w(e, "refreshAppInfoFromPics failed for appId=$appId") + null + } ?: return false + persistLatestSteamAppInfo(appId, fresh) + return true + } + private suspend fun persistLatestSteamAppInfo( appId: Int, remoteSteamApp: SteamApp, @@ -7470,42 +7783,189 @@ class SteamService : Service() { emptyMap() } + /** + * Prunes stale "{depotId}_{gid}.manifest" caches after a completed + * download: a branch switch or ordinary update leaves the previous + * build's manifests behind, wasting disk and — when depot.config is + * missing an entry — letting checkForAppUpdate's cache fallback mistake + * an old build for installed. Keeps only what depot.config says is + * current; legacy installs with no depot.config are left untouched + * because their fallback needs the cached files. Manifests with a + * pending ".stalecleanup" marker are also kept — the native stale-file + * pass still needs them to diff the old build's files away. ".filelist" + * sidecars (decrypted file lists the native pass reads without depot + * keys) follow the same lifecycle as their manifests. + */ + private fun pruneStaleDepotManifestCache(appDirPath: String) { + runCatching { + val installedManifests = readInstalledDepotManifestIds(appDirPath) + if (installedManifests.isEmpty()) return + val depotDownloaderDir = File(appDirPath, ".DepotDownloader") + depotDownloaderDir + .listFiles { f -> + f.isFile && (f.name.endsWith(".manifest") || f.name.endsWith(".filelist")) + } + ?.forEach { f -> + val stem = f.name.substringBeforeLast('.') + val parts = stem.split('_') + if (parts.size != 2) return@forEach + val depotId = parts[0].toIntOrNull() ?: return@forEach + val gid = parts[1].toLongOrNull() ?: return@forEach + if (installedManifests[depotId] == gid) return@forEach + if (File(depotDownloaderDir, "$stem.stalecleanup").isFile) return@forEach + if (f.delete()) { + Timber.i("Pruned stale depot manifest cache ${f.name} at $appDirPath") + } + } + // Markers with neither a manifest nor a filelist left can + // never be acted on. + depotDownloaderDir + .listFiles { f -> f.isFile && f.name.endsWith(".stalecleanup") } + ?.forEach { f -> + val stem = f.name.removeSuffix(".stalecleanup") + val actionable = + File(depotDownloaderDir, "$stem.manifest").isFile || + File(depotDownloaderDir, "$stem.filelist").isFile + if (!actionable && f.delete()) { + Timber.i("Dropped orphaned stale-cleanup marker ${f.name} at $appDirPath") + } + } + }.onFailure { e -> Timber.w(e, "Stale manifest prune failed for $appDirPath") } + } + + // The depot writer patches files in place (it never populates a + // .DepotDownloader/staging area), so a cancelled update can only clear + // the transient markers/progress here — the on-disk build is repaired by + // the restore verify, not by restoring staged originals. private fun cleanupCancelledUpdate(appDirPath: String) { MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) clearPersistedProgressSnapshot(appDirPath) + } + + /** + * A cancelled branch-switch update leaves selectedBranch pointing at a + * build that never landed and a half-old/half-new mix on disk. Restore + * the last committed selection (the "previousBranch" extra recorded when + * the picker changed it) and — when the cancelled download had already + * touched files — run the repair verify: it resolves the now-restored + * branch at download time and rewrites mismatched chunks, after which + * the native stale-file pass reclaims files unique to the aborted build. + * + * `previousBranch` is kept until the repair *completes* + * ([completeAppDownload] clears it), so it doubles as the durable + * "restore in progress" signal that labels the download and guards its + * cancel. No-op for updates that weren't a branch switch. + */ + private fun restoreCommittedBranchAfterCancelledUpdate( + appId: Int, + filesTouched: Boolean, + ) { + val svc = instance ?: return + runCatching { + val previous = + readSteamShortcutExtras(svc, appId)?.first?.get("previousBranch").orEmpty().trim() + if (previous.isEmpty()) return + // Store the restored branch verbatim — including "public" — so the + // selector and update detection read it authoritatively. Converting + // public to a blank extra would make recoverSelectedBetaName infer + // the branch from the (still-mixed) installed files and mislabel it. + if (!setSelectedBetaBranch(svc, appId, previous, recordPrevious = false)) return + + if (!filesTouched) { + // Nothing was written; the committed build is intact. Drop the + // restore point and we're done — no repair needed. + clearPreviousBetaBranch(svc, appId) + Timber.i("Cancelled update for appId=$appId (nothing written): restored branch '$previous'") + return + } - val stagingDir = File(File(appDirPath, ".DepotDownloader"), "staging") - if (!stagingDir.exists()) return + // Files were touched. Keep previousBranch as the restore-in-progress + // signal until the repair completes (the fresh verify rebuilds the + // depot.config entries the cancelled switch left in-progress). + Timber.i("Cancelled switch for appId=$appId: restoring branch '$previous', verifying files") + WinToast.show( + svc.applicationContext, + svc.getString(R.string.store_game_beta_branch_restoring, previous), + Toast.LENGTH_LONG, + ) + if (downloadAppForVerify(appId, isRestore = true) == null) { + // Dispatch failed before a coordinator record was created (nothing + // will retry it). Don't leave previousBranch dangling — it would + // later restore the wrong branch — give up cleanly to not-installed. + Timber.w("Restore verify dispatch returned null for appId=$appId; abandoning restore") + abandonInProgressRestore(appId, getAppDirPath(appId)) + } + }.onFailure { e -> + Timber.w(e, "Beta branch restore after cancelled update failed for appId=$appId") + } + } - stagingDir - .walkBottomUp() - .forEach { staged -> - if (staged == stagingDir) return@forEach - if (staged.isDirectory) { - if (staged.list().isNullOrEmpty()) staged.delete() - return@forEach - } + /** + * The user cancelled an in-progress restore verify. They accepted (via the + * cancel-warning dialog) that the half-repaired install must be + * re-downloaded from the store, so converge to a clean not-installed state + * without deleting files (a re-download reuses/repairs them): mark + * not-installed and drop the restore point. A re-download's fresh pass + * rebuilds any depot.config entries the cancelled switch left in-progress. + */ + private fun abandonInProgressRestore(appId: Int, appDirPath: String) { + instance?.let { clearPreviousBetaBranch(it, appId) } + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) + Timber.i("Restore verify abandoned for appId=$appId; marked not-installed, files left for re-download") + } - val relative = staged.relativeTo(stagingDir) - val finalFile = File(appDirPath, relative.path) - runCatching { - finalFile.parentFile?.mkdirs() - if (finalFile.exists()) { - finalFile.delete() - } - if (!staged.renameTo(finalFile)) { - staged.copyTo(finalFile, overwrite = true) - staged.delete() + private fun hasPreviousBetaBranch(appId: Int): Boolean { + val svc = instance ?: return false + return runCatching { + !readSteamShortcutExtras(svc, appId)?.first?.get("previousBranch").isNullOrBlank() + }.getOrDefault(false) + } + + private fun clearPreviousBetaBranch(context: Context, appId: Int) { + runCatching { + val shortcut = findSteamShortcut(context, appId) ?: return + if (!shortcut.getExtra("previousBranch").isNullOrEmpty()) { + shortcut.putExtra("previousBranch", null) + shortcut.saveData() + } + }.onFailure { e -> Timber.w(e, "Failed to clear previousBranch for appId=$appId") } + } + + /** + * Records the installed branch in `steam_settings/configs.app.ini` at + * download completion so a never-launched install (whose build may no + * longer match any branch's current PICS manifest) is still recoverable + * after a WinNative reinstall. Merges into an existing file (launch later + * rewrites it in full) or creates a minimal one. + */ + private fun persistInstalledBranchName(appId: Int, appDirPath: String) { + runCatching { + val branch = resolveSelectedBetaName(appId).ifBlank { "public" } + val ini = File(appDirPath, "steam_settings/configs.app.ini") + ini.parentFile?.mkdirs() + if (ini.isFile) { + val lines = ini.readLines().toMutableList() + val idx = lines.indexOfFirst { it.trimStart().startsWith("branch_name=") } + if (idx >= 0) { + lines[idx] = "branch_name=$branch" + } else { + val genIdx = + lines.indexOfFirst { it.trim().equals("[app::general]", ignoreCase = true) } + if (genIdx >= 0) { + lines.add(genIdx + 1, "branch_name=$branch") + } else { + lines.add("[app::general]") + lines.add("branch_name=$branch") } - }.onFailure { - Timber.w(it, "Failed to restore staged Steam update file ${staged.absolutePath}") } + ini.writeText(lines.joinToString("\n", postfix = "\n")) + } else { + ini.writeText("[app::general]\nbranch_name=$branch\n") } - - if (stagingDir.exists() && stagingDir.list().isNullOrEmpty()) { - stagingDir.delete() - } + Timber.i("Persisted installed branch_name='$branch' for appId=$appId") + }.onFailure { e -> Timber.w(e, "Failed to persist installed branch_name for appId=$appId") } } suspend fun checkDlcOwnershipViaPICSBatch(dlcAppIds: Set): Set { @@ -7584,7 +8044,10 @@ class SteamService : Service() { if (record.taskType == DownloadRecord.TASK_UPDATE) { downloadAppForUpdate(appId, persistedIds) } else if (record.taskType == DownloadRecord.TASK_VERIFY) { - downloadAppForVerify(appId) + // A pending previousBranch extra means this verify is the + // restore of a cancelled switch — keep it labelled across a + // requeue / app restart. + downloadAppForVerify(appId, isRestore = hasPreviousBetaBranch(appId)) } else { downloadApp(appId, persistedIds) } @@ -7615,10 +8078,20 @@ class SteamService : Service() { info.cancel("Cancelled by user") } kotlinx.coroutines.CoroutineScope(Dispatchers.IO).launch { - val isUpdateTask = record.taskType == DownloadRecord.TASK_UPDATE + // VERIFY only exists for installed games: a cancelled one + // must clean up like an update, never fall through to the + // install-cancel branch that deletes the game directory. + val isUpdateTask = + record.taskType == DownloadRecord.TASK_UPDATE || + record.taskType == DownloadRecord.TASK_VERIFY info?.awaitCompletion(timeoutMs = if (isUpdateTask) 10000L else 3000L) val appDirPath = record.installPath.ifEmpty { getAppDirPath(appId) } if (isUpdateTask) { + val isVerify = record.taskType == DownloadRecord.TASK_VERIFY + // A restore verify is durably identified by the pending + // previousBranch extra (set on the cancelled switch, cleared + // only when a download completes). + val isRestoreVerify = isVerify && hasPreviousBetaBranch(appId) val updateNeverStarted = statusAtCancel == DownloadPhase.QUEUED || ( @@ -7639,6 +8112,25 @@ class SteamService : Service() { } info?.updateStatus(DownloadPhase.CANCELLED) removeDownloadJob(appId, forceRemove = true) + when { + isRestoreVerify -> + // User aborted the restore — converge to not-installed. + abandonInProgressRestore(appId, appDirPath) + isVerify -> { + // Normal verify of an installed game: keep it installed. + // (depot.config entries the cancelled verify left + // in-progress are rebuilt by the next verify/update.) + if (!updateNeverStarted) { + MarkerUtils.addMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) + } + } + else -> + // UPDATE cancel: revert the branch and repair if files changed. + restoreCommittedBranchAfterCancelledUpdate( + appId, + filesTouched = !updateNeverStarted, + ) + } return@launch } val dirFile = java.io.File(appDirPath) diff --git a/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt b/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt index 64fe42af2..fef57ca0f 100644 --- a/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt +++ b/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt @@ -198,6 +198,7 @@ fun WnKeyValue.generateSteamApp(): SteamApp = workingDir = it["workingdir"].value?.replace('\\', '/').orEmpty(), description = it["description"].value.orEmpty(), type = it["type"].value.orEmpty(), + arguments = it["arguments"].value.orEmpty(), configOS = OS.from(it["config"]["oslist"].value), configArch = OSArch.from(it["config"]["osarch"].value), ) diff --git a/app/src/main/feature/stores/steam/utils/SteamUtils.kt b/app/src/main/feature/stores/steam/utils/SteamUtils.kt index 0b022ffa0..a854be1c7 100644 --- a/app/src/main/feature/stores/steam/utils/SteamUtils.kt +++ b/app/src/main/feature/stores/steam/utils/SteamUtils.kt @@ -1178,14 +1178,15 @@ object SteamUtils { val dlcApps = SteamService.getDownloadableDlcAppsOf(appId) val hiddenDlcApps = SteamService.getHiddenDlcAppsOf(appId) val appendedDlcIds = mutableListOf() + val selectedBranch = SteamService.resolveSelectedBetaName(appId).ifBlank { "public" } val appIniContent = buildString { - // [app::general] — make Steam_Apps::GetCurrentBetaName() - // deterministic; WinNative always installs the public branch. + // [app::general] — communicate the active branch to gbe_fork so + // GetCurrentBetaName() and GetAppBuildId() return correct values. appendLine("[app::general]") - appendLine("is_beta_branch=0") - appendLine("branch_name=public") + appendLine("is_beta_branch=${if (selectedBranch.equals("public", ignoreCase = true)) 0 else 1}") + appendLine("branch_name=$selectedBranch") appendLine() appendLine("[app::dlcs]") appendLine("unlock_all=0") @@ -1399,15 +1400,39 @@ object SteamUtils { } } + /** + * Combines a launch option's own arguments (appinfo `config.launch` entry, persisted + * as the shortcut's `launch_exe_args` extra) with the user's custom args. Honors + * Steam's `%command%` placeholder in the custom args: the option args stay attached + * to the command itself so a user wrapper like `FOO=1 %command% -windowed` keeps + * working. + */ + @JvmStatic + fun combineSteamLaunchArgs( + selectedArgs: String?, + customArgs: String?, + ): String { + val selected = selectedArgs.orEmpty().trim() + val custom = customArgs.orEmpty().trim() + if (selected.isEmpty()) return custom + if (custom.isEmpty()) return selected + return if (custom.contains("%command%")) { + custom.replace("%command%", "%command% $selected") + } else { + "$selected $custom" + } + } + @JvmStatic fun updateOrModifyLocalConfig( imageFs: ImageFs, container: Container, appId: String, steamUserId64: String, + selectedLaunchArgs: String = "", ) { try { - val exeCommandLine = container.execArgs + val exeCommandLine = combineSteamLaunchArgs(selectedLaunchArgs, container.execArgs) com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient .setLaunchCommandLine(exeCommandLine) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5d203471c..c9c4fdf4e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -116,6 +116,11 @@ Update check failed Update Workshop + Launch Options + Couldn\'t save launch option + Beta Branch + Couldn\'t save beta branch + Branch switch cancelled — restoring \'%1$s\' files Verify Files Verifying %1$s — check the Downloads tab Steam options @@ -264,6 +269,7 @@ Finalizing Unpacking Updating + Restoring Complete Cancelled Failed: %1$s @@ -275,6 +281,9 @@ Cancelling a DLC, Game, or Update download can potentially corrupt your game install.\n\nAre you sure you want to cancel this Download and delete the files downloaded so far? Cancel All Downloads? Cancelling DLC, Game, or Update downloads can potentially corrupt your game installs.\n\nAre you sure you want to cancel all Downloads and delete the files downloaded so far? + Cancel Restore + Cancel Restore? + This is a restore download — it is repairing your game back to the previous branch after a cancelled switch.\n\nCancelling it leaves the game unplayable, and you will need to re-download it from the store. Are you sure? this game No active downloads. diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index d6e9ed01c..94206b528 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -7523,8 +7523,9 @@ private String getWineStartCommand(GuestProgramLauncherComponent launcherCompone int appId = Integer.parseInt(shortcut.getExtra("app_id")); // Reset per launch; set below once the launch exe is resolved. wnSteamDirectExeOverride = false; - String steamExtraArgs = shortcut.getSettingExtra("execArgs", container.getExecArgs()); - steamExtraArgs = (steamExtraArgs != null && !steamExtraArgs.isEmpty()) ? " " + steamExtraArgs : ""; + String steamExtraArgs = + combineWithSelectedLaunchArgs(shortcut.getSettingExtra("execArgs", container.getExecArgs())); + steamExtraArgs = !steamExtraArgs.isEmpty() ? " " + steamExtraArgs : ""; boolean useColdClient = parseBoolean(getShortcutSetting("useColdClient", container.isUseColdClient() ? "1" : "0")); boolean launchBionicSteam = isBionicSteamEnabledForShortcut(); @@ -7910,7 +7911,7 @@ private void writeColdClientIniDirect(int appId, String gameDirName, String rela } String perGameExecArgs = shortcut != null ? shortcut.getSettingExtra("execArgs", container.getExecArgs()) : container.getExecArgs(); - String exeCommandLine = perGameExecArgs != null ? perGameExecArgs : ""; + String exeCommandLine = combineWithSelectedLaunchArgs(perGameExecArgs); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); @@ -7986,7 +7987,7 @@ private void writeColdClientIniForLaunch(int appId, String gameInstallPath, Stri } String perGameExecArgs = shortcut != null ? shortcut.getSettingExtra("execArgs", container.getExecArgs()) : container.getExecArgs(); - String exeCommandLine = perGameExecArgs != null ? perGameExecArgs : ""; + String exeCommandLine = combineWithSelectedLaunchArgs(perGameExecArgs); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); @@ -8111,6 +8112,16 @@ private static String exeBaseName(String path) { return normalized.trim(); } + /** + * The selected Steam launch option's own arguments (shortcut extra + * {@code launch_exe_args}) merged with the given custom args — the single + * resolver every Steam launch channel uses for its effective command line. + */ + private String combineWithSelectedLaunchArgs(String customArgs) { + return SteamUtils.combineSteamLaunchArgs( + shortcut != null ? shortcut.getExtra("launch_exe_args") : "", customArgs); + } + private String resolveRelativeGameExe(int appId, String gameInstPath) { // Per-game launch_exe_path wins over the shared container cache. String shortcutExePath = resolveShortcutSteamExecutablePath(gameInstPath); @@ -9663,10 +9674,17 @@ private void setupSteamEnvironment(int appId, File gameDir) { // Stamp-cache the registry edits + userdata reconcile + local-config // edit so warm launches of the same game in the same container skip // the per-launch file-copy / VDF-parse work. Stamp key is - // appId|userDataId — change either and the work re-runs. + // appId|userDataId|launchOptionsHash — change any and the work re-runs. File steamEnvStamp = new File(winePrefix, ".wine/drive_c/.wn-steamenv-" + appId + "-" + steamUserDataId + ".stamp"); - String expectedStamp = "v1|" + appId + "|" + steamUserDataId; + String selectedLaunchArgs = shortcut != null ? shortcut.getExtra("launch_exe_args") : ""; + // The effective LaunchOptions line is part of the stamp so a changed + // launch-option selection (or custom args) re-runs the localconfig edit. + // Raw string, not a hash: equality must be exact (no collisions). + String effectiveLaunchOptions = + SteamUtils.combineSteamLaunchArgs(selectedLaunchArgs, container.getExecArgs()); + String expectedStamp = "v2|" + appId + "|" + steamUserDataId + + "|" + effectiveLaunchOptions; String existingStamp = steamEnvStamp.exists() ? FileUtils.readString(steamEnvStamp).trim() : ""; boolean steamEnvWarm = expectedStamp.equals(existingStamp); @@ -9681,7 +9699,8 @@ private void setupSteamEnvironment(int appId, File gameDir) { skipFirstTimeSteamSetup(winePrefix); reconcileSteamUserdata(steamDir, steamUserDataId, steamId64); - SteamUtils.updateOrModifyLocalConfig(imageFs, container, String.valueOf(appId), steamUserDataId); + SteamUtils.updateOrModifyLocalConfig(imageFs, container, String.valueOf(appId), steamUserDataId, + selectedLaunchArgs); setupLightweightSteamConfig(steamDir, steamUserDataId); try { @@ -9691,6 +9710,10 @@ private void setupSteamEnvironment(int appId, File gameDir) { "Failed to write steam-env stamp at " + steamEnvStamp.getPath(), e); } } else { + // localconfig.vdf is already up to date, but the pushed launch command + // line is per-process native state — re-push it on warm launches too. + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient.INSTANCE + .setLaunchCommandLine(effectiveLaunchOptions); Log.d("XServerDisplayActivity", "Steam env warm-cache hit (appId=" + appId + ", userId=" + steamUserDataId + ") — skipping reconcile + autoLogin");