From 9af3014ef705fb574fe08e16fac61467f0ba49c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 9 Jun 2026 23:24:56 +0000 Subject: [PATCH 01/25] feat(steam): Launch Options selector in the STEAM dropdown Adds a "Launch Options" item to the STEAM menu on the game detail screen that lists the game's appinfo config.launch entries (e.g. ARK's "Launch ARK (No BattlEye)") with a checkmark on the current choice. Selecting one persists as the game's default: - exe -> shortcut extra launch_exe_path + container.executablePath (the priority resolveRelativeGameExe already honors; non-default exes ride the existing WN_STEAM_DIRECT_EXE override) - the option's own arguments -> new shortcut extra launch_exe_args, kept separate from the user's custom execArgs LaunchInfo gains an `arguments` field (defaulted, so cached appinfo JSON stays decodable) parsed from appinfo config/launch/arguments. A shared SteamUtils.combineSteamLaunchArgs joins option args with custom args across all three launch channels (direct command line, ColdClientLoader.ini ExeCommandLine, localconfig.vdf LaunchOptions / GetLaunchCommandLine). It honors Steam's %command% placeholder in custom args. The steam-env warm stamp now includes the effective LaunchOptions line so a changed selection re-runs the localconfig edit, and warm launches re-push the per-process launch command line. The submenu reuses the existing StoreSourceActionPopup surface and StoreSourceMenuItem styling; hidden unless the game is installed and has 2+ distinct options. https://claude.ai/code/session_01EUsZxzAWCjSh8LYRS9W3vT --- .../main/app/shell/StoreGameDetailScreen.kt | 153 ++++++++++++++---- app/src/main/app/shell/UnifiedActivity.kt | 61 +++++++ .../feature/stores/steam/data/LaunchInfo.kt | 2 + .../stores/steam/service/SteamService.kt | 56 +++++++ .../stores/steam/utils/KeyValueUtils.kt | 1 + .../feature/stores/steam/utils/SteamUtils.kt | 26 ++- app/src/main/res/values/strings.xml | 2 + .../display/XServerDisplayActivity.java | 29 +++- 8 files changed, 295 insertions(+), 35 deletions(-) diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt index 64b4fbd8e..45d7b3fcc 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -48,6 +48,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.automirrored.outlined.FactCheck import androidx.compose.material.icons.outlined.ArrowDropDown +import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.CloudSync import androidx.compose.material.icons.outlined.Construction import androidx.compose.material.icons.outlined.Delete @@ -57,6 +58,7 @@ import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material.icons.outlined.Extension import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.RocketLaunch import androidx.compose.material.icons.outlined.SportsEsports import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.SystemUpdate @@ -113,6 +115,13 @@ internal data class StoreDlcItem( val isInstalled: Boolean = false, ) +internal data class StoreLaunchOptionItem( + // Relative path, '/'-separated (appinfo config.launch entry). + val executable: String, + val arguments: String, + val label: String, +) + private val StoreBlack = Color.Black private val StoreCard = Color(0xFF12121B) private val StoreAccent = Color(0xFF1A9FFF) @@ -149,6 +158,9 @@ internal fun StoreGameDetailScreen( showWorkshop: Boolean = false, showVerifyFiles: Boolean = false, areSteamActionsEnabled: Boolean = true, + launchOptions: List = emptyList(), + selectedLaunchOption: StoreLaunchOptionItem? = null, + onSelectLaunchOption: (StoreLaunchOptionItem) -> Unit = {}, dlcs: List = emptyList(), selectedDlcIds: Set = emptySet(), isDlcSelectionEnabled: Boolean = true, @@ -186,7 +198,9 @@ internal fun StoreGameDetailScreen( val showUpdateCta = updateCheckAvailable && isUpdateAvailable val verifyFilesAvailable = showVerifyFiles && isInstalled val workshopAvailable = showWorkshop && isInstalled - val sourceMenuEnabled = updateCheckAvailable || verifyFilesAvailable || workshopAvailable + val launchOptionsAvailable = isInstalled && launchOptions.size >= 2 + val sourceMenuEnabled = + updateCheckAvailable || verifyFilesAvailable || workshopAvailable || launchOptionsAvailable val showDlcCard = dlcs.isNotEmpty() val showActionColumn = showDownloadCta || showUpdateCta || @@ -293,6 +307,9 @@ internal fun StoreGameDetailScreen( showCheckForUpdate = updateCheckAvailable, showVerifyFiles = verifyFilesAvailable, showWorkshop = workshopAvailable, + showLaunchOptions = launchOptionsAvailable, + launchOptions = launchOptions, + selectedLaunchOption = selectedLaunchOption, isCheckingForUpdate = isCheckingForUpdate, areSteamActionsEnabled = areSteamActionsEnabled, isUpdateCheckEnabled = @@ -303,6 +320,7 @@ internal fun StoreGameDetailScreen( onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, + onSelectLaunchOption = onSelectLaunchOption, ) } @@ -780,14 +798,19 @@ private fun StoreSourceTag( showCheckForUpdate: Boolean = false, showVerifyFiles: Boolean = false, showWorkshop: Boolean = false, + showLaunchOptions: Boolean = false, + launchOptions: List = emptyList(), + selectedLaunchOption: StoreLaunchOptionItem? = null, isCheckingForUpdate: Boolean = false, areSteamActionsEnabled: Boolean = true, isUpdateCheckEnabled: Boolean = true, onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, + onSelectLaunchOption: (StoreLaunchOptionItem) -> Unit = {}, ) { var menuOpen by remember { mutableStateOf(false) } + var launchOptionsPage by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableIntStateOf(0) } Box { Surface( @@ -797,7 +820,16 @@ private fun StoreSourceTag( modifier = Modifier .onSizeChanged { anchorHeightPx = it.height } - .then(if (menuEnabled) Modifier.clickable { menuOpen = true } else Modifier), + .then( + if (menuEnabled) { + Modifier.clickable { + launchOptionsPage = false + menuOpen = true + } + } else { + Modifier + }, + ), ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), @@ -832,34 +864,63 @@ private fun StoreSourceTag( val gapPx = with(LocalDensity.current) { 6.dp.roundToPx() } StoreSourceActionPopup( expanded = menuOpen, - onDismissRequest = { menuOpen = false }, + onDismissRequest = { + menuOpen = false + launchOptionsPage = false + }, offset = IntOffset(0, anchorHeightPx + gapPx), ) { - if (showVerifyFiles) { + if (launchOptionsPage) { StoreSourceMenuItem( - icon = Icons.AutoMirrored.Outlined.FactCheck, - label = stringResource(R.string.store_game_verify_files), - enabled = areSteamActionsEnabled && !isCheckingForUpdate, - ) { menuOpen = false; onVerifyFiles() } - } - if (showCheckForUpdate) { - StoreSourceMenuItem( - icon = Icons.Outlined.Refresh, - label = - if (isCheckingForUpdate) { - stringResource(R.string.store_game_checking_for_update) - } else { - stringResource(R.string.store_game_check_for_update) - }, - enabled = areSteamActionsEnabled && isUpdateCheckEnabled, - ) { menuOpen = false; onCheckForUpdate() } - } - if (showWorkshop) { - StoreSourceMenuItem( - icon = Icons.Outlined.Construction, - label = stringResource(R.string.store_game_workshop), - enabled = areSteamActionsEnabled, - ) { menuOpen = false; onWorkshop() } + icon = Icons.AutoMirrored.Outlined.ArrowBack, + label = stringResource(R.string.store_game_launch_options), + ) { launchOptionsPage = false } + StoreDlcDivider() + launchOptions.forEach { option -> + StoreLaunchOptionRow( + label = option.label, + selected = option == selectedLaunchOption, + enabled = areSteamActionsEnabled, + ) { + menuOpen = false + launchOptionsPage = false + onSelectLaunchOption(option) + } + } + } else { + if (showVerifyFiles) { + StoreSourceMenuItem( + icon = Icons.AutoMirrored.Outlined.FactCheck, + label = stringResource(R.string.store_game_verify_files), + enabled = areSteamActionsEnabled && !isCheckingForUpdate, + ) { menuOpen = false; onVerifyFiles() } + } + if (showCheckForUpdate) { + StoreSourceMenuItem( + icon = Icons.Outlined.Refresh, + label = + if (isCheckingForUpdate) { + stringResource(R.string.store_game_checking_for_update) + } else { + stringResource(R.string.store_game_check_for_update) + }, + enabled = areSteamActionsEnabled && isUpdateCheckEnabled, + ) { menuOpen = false; onCheckForUpdate() } + } + if (showWorkshop) { + StoreSourceMenuItem( + icon = Icons.Outlined.Construction, + label = stringResource(R.string.store_game_workshop), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onWorkshop() } + } + if (showLaunchOptions) { + StoreSourceMenuItem( + icon = Icons.Outlined.RocketLaunch, + label = stringResource(R.string.store_game_launch_options), + enabled = areSteamActionsEnabled, + ) { launchOptionsPage = true } + } } } } @@ -948,6 +1009,44 @@ private fun StoreSourceMenuItem( } } +@Composable +private fun StoreLaunchOptionRow( + label: String, + selected: Boolean, + enabled: Boolean = true, + onClick: () -> Unit, +) { + val contentColor = if (enabled) Color.White else Color.White.copy(alpha = 0.45f) + Row( + modifier = + Modifier + .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) + .padding(start = 14.dp, end = 14.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box(Modifier.size(16.dp), contentAlignment = Alignment.Center) { + if (selected) { + Icon( + Icons.Outlined.Check, + contentDescription = null, + tint = StoreAccent, + modifier = Modifier.size(16.dp), + ) + } + } + Text( + label, + color = if (selected) StoreAccent else contentColor, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 280.dp), + ) + } +} + @Composable private fun StoreStatChip( icon: ImageVector, diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 80a3e4e86..b868fae39 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -8801,6 +8801,8 @@ class UnifiedActivity : var isCheckingForUpdate by remember(app.id) { mutableStateOf(false) } var isUpdateCheckCoolingDown by remember(app.id) { mutableStateOf(false) } var showWorkshopDialog by remember(app.id) { mutableStateOf(false) } + var launchOptions by remember(app.id) { mutableStateOf>(emptyList()) } + var selectedLaunchOption by remember(app.id) { mutableStateOf(null) } var updateInfo by remember(app.id) { mutableStateOf(null) } var updateStatusText by remember(app.id) { mutableStateOf(null) } val downloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState( @@ -8866,6 +8868,41 @@ class UnifiedActivity : } } + LaunchedEffect(app.id, installed) { + if (installed != true) { + launchOptions = emptyList() + selectedLaunchOption = null + return@LaunchedEffect + } + val (options, selected) = + withContext(Dispatchers.IO) { + val appDir = java.io.File(SteamService.getAppDirPath(app.id)) + val allOptions = + SteamService + .getWindowsLaunchInfos(app.id) + .map { info -> + StoreLaunchOptionItem( + executable = info.executable, + arguments = info.arguments, + label = info.description.ifBlank { info.executable.substringAfterLast('/') }, + ) + }.distinctBy { it.executable.lowercase() to it.arguments } + // Hide options whose exe is missing on disk, but if that empties a + // non-empty list (case-sensitivity quirks) keep the unfiltered set. + val onDisk = allOptions.filter { java.io.File(appDir, it.executable).isFile } + val options = onDisk.ifEmpty { allOptions } + val (selectedExe, selectedArgs) = SteamService.getSelectedLaunchOption(context, app.id) + val selected = + options.firstOrNull { + it.executable.equals(selectedExe, ignoreCase = true) && it.arguments == selectedArgs + } ?: options.firstOrNull { it.executable.equals(selectedExe, ignoreCase = true) } + ?: options.firstOrNull() + options to selected + } + launchOptions = options + selectedLaunchOption = selected + } + val totalDownloadSize = selectedManifestSizes.downloadSize val totalInstallSize = selectedManifestSizes.installSize val defaultPathSet = @@ -8986,6 +9023,30 @@ class UnifiedActivity : showWorkshop = isReallyInstalled, showVerifyFiles = isReallyInstalled, areSteamActionsEnabled = !hasBlockingSteamDownload, + launchOptions = launchOptions, + selectedLaunchOption = selectedLaunchOption, + onSelectLaunchOption = { option -> + scope.launch(Dispatchers.IO) { + val saved = + SteamService.setSelectedLaunchOption( + applicationContext, + app.id, + option.executable, + option.arguments, + ) + withContext(Dispatchers.Main) { + if (saved) { + selectedLaunchOption = option + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString(R.string.store_game_launch_option_failed), + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } + }, dlcs = dlcItems, selectedDlcIds = selectedDlcIds.toSet(), isDlcSelectionEnabled = steamDownloadRecord == null, diff --git a/app/src/main/feature/stores/steam/data/LaunchInfo.kt b/app/src/main/feature/stores/steam/data/LaunchInfo.kt index 75d747ae1..caf3ec823 100644 --- a/app/src/main/feature/stores/steam/data/LaunchInfo.kt +++ b/app/src/main/feature/stores/steam/data/LaunchInfo.kt @@ -11,6 +11,8 @@ data class LaunchInfo( val workingDir: String, val description: String, val type: String, + // Default keeps already-cached appinfo JSON (without this key) decodable. + val arguments: String = "", @Serializable(with = OsEnumSetSerializer::class) val configOS: java.util.EnumSet, val configArch: OSArch, diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index d2f6cefd3..c054ef01b 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -2306,6 +2306,62 @@ class SteamService : Service() { return container.executablePath.ifEmpty { getInstalledExe(gameId) } } + private fun findSteamShortcut( + context: Context, + appId: Int, + ) = ContainerManager(context).loadShortcuts().find { + it.getExtra("game_source") == "STEAM" && it.getExtra("app_id") == appId.toString() + } + + /** + * Persists the user's launch-option choice (an appinfo `config.launch` entry) on + * the game's shortcut + container, matching resolveRelativeGameExe's priority: + * shortcut `launch_exe_path` first, container.executablePath as the synced + * fallback. The option's own arguments go to `launch_exe_args`, kept separate + * from the user-editable custom args (`execArgs`). Call on an IO dispatcher. + */ + fun setSelectedLaunchOption( + context: Context, + appId: Int, + executable: String, + arguments: String, + ): Boolean { + var shortcut = findSteamShortcut(context, appId) + if (shortcut == null) { + // Installs from older builds may predate shortcut creation on download. + createSteamShortcut(context, appId) + shortcut = findSteamShortcut(context, appId) + } + if (shortcut == null) { + Timber.w("setSelectedLaunchOption: no shortcut for appId=$appId") + return false + } + shortcut.putExtra("launch_exe_path", executable) + shortcut.putExtra("launch_exe_args", arguments.ifBlank { null }) + shortcut.saveData() + shortcut.container?.let { + it.executablePath = executable + it.saveData() + } + return true + } + + /** + * Currently effective launch option as (executable, arguments), resolved in the + * same order the launch path uses. Call on an IO dispatcher. + */ + fun getSelectedLaunchOption( + context: Context, + appId: Int, + ): Pair { + val shortcut = findSteamShortcut(context, appId) + val exe = + shortcut?.getExtra("launch_exe_path").orEmpty().ifBlank { + shortcut?.container?.executablePath.orEmpty().ifBlank { getInstalledExe(appId) } + } + return exe.replace('\\', '/') to shortcut?.getExtra("launch_exe_args").orEmpty() + } + suspend fun deleteApp(appId: Int): Boolean = withContext(Dispatchers.IO) { val appDirPath = getAppDirPath(appId) diff --git a/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt b/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt index 64fe42af2..fef57ca0f 100644 --- a/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt +++ b/app/src/main/feature/stores/steam/utils/KeyValueUtils.kt @@ -198,6 +198,7 @@ fun WnKeyValue.generateSteamApp(): SteamApp = workingDir = it["workingdir"].value?.replace('\\', '/').orEmpty(), description = it["description"].value.orEmpty(), type = it["type"].value.orEmpty(), + arguments = it["arguments"].value.orEmpty(), configOS = OS.from(it["config"]["oslist"].value), configArch = OSArch.from(it["config"]["osarch"].value), ) diff --git a/app/src/main/feature/stores/steam/utils/SteamUtils.kt b/app/src/main/feature/stores/steam/utils/SteamUtils.kt index 0b022ffa0..acde7feec 100644 --- a/app/src/main/feature/stores/steam/utils/SteamUtils.kt +++ b/app/src/main/feature/stores/steam/utils/SteamUtils.kt @@ -1399,15 +1399,39 @@ object SteamUtils { } } + /** + * Combines a launch option's own arguments (appinfo `config.launch` entry, persisted + * as the shortcut's `launch_exe_args` extra) with the user's custom args. Honors + * Steam's `%command%` placeholder in the custom args: the option args stay attached + * to the command itself so a user wrapper like `FOO=1 %command% -windowed` keeps + * working. + */ + @JvmStatic + fun combineSteamLaunchArgs( + selectedArgs: String?, + customArgs: String?, + ): String { + val selected = selectedArgs.orEmpty().trim() + val custom = customArgs.orEmpty().trim() + if (selected.isEmpty()) return custom + if (custom.isEmpty()) return selected + return if (custom.contains("%command%")) { + custom.replace("%command%", "%command% $selected") + } else { + "$selected $custom" + } + } + @JvmStatic fun updateOrModifyLocalConfig( imageFs: ImageFs, container: Container, appId: String, steamUserId64: String, + selectedLaunchArgs: String = "", ) { try { - val exeCommandLine = container.execArgs + val exeCommandLine = combineSteamLaunchArgs(selectedLaunchArgs, container.execArgs) com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient .setLaunchCommandLine(exeCommandLine) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c6b4636e5..2ec31c002 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -116,6 +116,8 @@ Update check failed Update Workshop + Launch Options + Couldn\'t save launch option Verify Files Verifying %1$s — check the Downloads tab Steam options diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index 51b37c8b0..b241e0b6f 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -7493,8 +7493,10 @@ private String getWineStartCommand(GuestProgramLauncherComponent launcherCompone int appId = Integer.parseInt(shortcut.getExtra("app_id")); // Reset per launch; set below once the launch exe is resolved. wnSteamDirectExeOverride = false; - String steamExtraArgs = shortcut.getSettingExtra("execArgs", container.getExecArgs()); - steamExtraArgs = (steamExtraArgs != null && !steamExtraArgs.isEmpty()) ? " " + steamExtraArgs : ""; + String steamExtraArgs = SteamUtils.combineSteamLaunchArgs( + shortcut.getExtra("launch_exe_args"), + shortcut.getSettingExtra("execArgs", container.getExecArgs())); + steamExtraArgs = !steamExtraArgs.isEmpty() ? " " + steamExtraArgs : ""; boolean useColdClient = parseBoolean(getShortcutSetting("useColdClient", container.isUseColdClient() ? "1" : "0")); boolean launchBionicSteam = isBionicSteamEnabledForShortcut(); @@ -7880,7 +7882,8 @@ private void writeColdClientIniDirect(int appId, String gameDirName, String rela } String perGameExecArgs = shortcut != null ? shortcut.getSettingExtra("execArgs", container.getExecArgs()) : container.getExecArgs(); - String exeCommandLine = perGameExecArgs != null ? perGameExecArgs : ""; + String exeCommandLine = SteamUtils.combineSteamLaunchArgs( + shortcut != null ? shortcut.getExtra("launch_exe_args") : "", perGameExecArgs); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); @@ -7956,7 +7959,8 @@ private void writeColdClientIniForLaunch(int appId, String gameInstallPath, Stri } String perGameExecArgs = shortcut != null ? shortcut.getSettingExtra("execArgs", container.getExecArgs()) : container.getExecArgs(); - String exeCommandLine = perGameExecArgs != null ? perGameExecArgs : ""; + String exeCommandLine = SteamUtils.combineSteamLaunchArgs( + shortcut != null ? shortcut.getExtra("launch_exe_args") : "", perGameExecArgs); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); @@ -9633,10 +9637,16 @@ private void setupSteamEnvironment(int appId, File gameDir) { // Stamp-cache the registry edits + userdata reconcile + local-config // edit so warm launches of the same game in the same container skip // the per-launch file-copy / VDF-parse work. Stamp key is - // appId|userDataId — change either and the work re-runs. + // appId|userDataId|launchOptionsHash — change any and the work re-runs. File steamEnvStamp = new File(winePrefix, ".wine/drive_c/.wn-steamenv-" + appId + "-" + steamUserDataId + ".stamp"); - String expectedStamp = "v1|" + appId + "|" + steamUserDataId; + String selectedLaunchArgs = shortcut != null ? shortcut.getExtra("launch_exe_args") : ""; + // The effective LaunchOptions line is part of the stamp so a changed + // launch-option selection (or custom args) re-runs the localconfig edit. + String effectiveLaunchOptions = + SteamUtils.combineSteamLaunchArgs(selectedLaunchArgs, container.getExecArgs()); + String expectedStamp = "v2|" + appId + "|" + steamUserDataId + + "|" + effectiveLaunchOptions.hashCode(); String existingStamp = steamEnvStamp.exists() ? FileUtils.readString(steamEnvStamp).trim() : ""; boolean steamEnvWarm = expectedStamp.equals(existingStamp); @@ -9651,7 +9661,8 @@ private void setupSteamEnvironment(int appId, File gameDir) { skipFirstTimeSteamSetup(winePrefix); reconcileSteamUserdata(steamDir, steamUserDataId, steamId64); - SteamUtils.updateOrModifyLocalConfig(imageFs, container, String.valueOf(appId), steamUserDataId); + SteamUtils.updateOrModifyLocalConfig(imageFs, container, String.valueOf(appId), steamUserDataId, + selectedLaunchArgs); setupLightweightSteamConfig(steamDir, steamUserDataId); try { @@ -9661,6 +9672,10 @@ private void setupSteamEnvironment(int appId, File gameDir) { "Failed to write steam-env stamp at " + steamEnvStamp.getPath(), e); } } else { + // localconfig.vdf is already up to date, but the pushed launch command + // line is per-process native state — re-push it on warm launches too. + com.winlator.cmod.feature.stores.steam.wnsteam.WnLibSteamClient.INSTANCE + .setLaunchCommandLine(effectiveLaunchOptions); Log.d("XServerDisplayActivity", "Steam env warm-cache hit (appId=" + appId + ", userId=" + steamUserDataId + ") — skipping reconcile + autoLogin"); From 52fec94960ca202896fb2f53be4bc6eef1260b28 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 03:08:15 +0000 Subject: [PATCH 02/25] fix(steam): show Launch Options on the Library game screen too The v1 selector only existed on the store detail screen (StoreGameDetailScreen); tapping an installed game in the library grid opens LibraryGameDetailDialog -> LibraryGameLaunchScreen, which has its own duplicated STEAM dropdown - so the feature was invisible from the library. Mirror the submenu there (same two-page popup, using this screen's Launch* styling) and wire it in LibraryGameDetailDialog for Steam games only. Also fix two latent issues that could hide or weaken the selector: - Cached appinfo rows predate LaunchInfo.arguments, so options that differ only by args deduped down to one entry and fell under the 2-option visibility gate. Dedup now includes the option label, and both dialogs do a two-phase load: show cached options immediately, then SteamService.refreshAppInfoFromPics (new public wrapper around fetchLatestSteamAppInfo + persistLatestSteamAppInfo) re-fetches the app's PICS appinfo and reloads, healing upgraded installs on first open. - Extracted the option-list builder and selection persistence into shared UnifiedActivity helpers used by both screens, replacing GameManagerDialog's inline copies. https://claude.ai/code/session_01EUsZxzAWCjSh8LYRS9W3vT --- .../main/app/shell/LibraryGameLaunchScreen.kt | 137 ++++++++++++++--- app/src/main/app/shell/UnifiedActivity.kt | 145 ++++++++++++------ .../stores/steam/service/SteamService.kt | 17 ++ 3 files changed, 233 insertions(+), 66 deletions(-) diff --git a/app/src/main/app/shell/LibraryGameLaunchScreen.kt b/app/src/main/app/shell/LibraryGameLaunchScreen.kt index 9f1bac0b2..efba8a7ea 100644 --- a/app/src/main/app/shell/LibraryGameLaunchScreen.kt +++ b/app/src/main/app/shell/LibraryGameLaunchScreen.kt @@ -43,12 +43,14 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.automirrored.outlined.FactCheck import androidx.compose.material.icons.outlined.ArrowDropDown +import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.CloudSync import androidx.compose.material.icons.outlined.Construction import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.DesktopWindows import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.RocketLaunch import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.PlayArrow import androidx.compose.material.icons.outlined.Save @@ -58,6 +60,7 @@ import androidx.compose.material.icons.outlined.SportsEsports import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.Warning import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -133,6 +136,9 @@ internal fun LibraryGameLaunchScreen( showVerifyFiles: Boolean = true, showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, + launchOptions: List = emptyList(), + selectedLaunchOption: StoreLaunchOptionItem? = null, + onSelectLaunchOption: (StoreLaunchOptionItem) -> Unit = {}, playEnabled: Boolean = true, playDisabledLabel: String? = null, onBack: () -> Unit, @@ -268,10 +274,14 @@ internal fun LibraryGameLaunchScreen( showVerifyFiles = showVerifyFiles, showCheckForUpdate = showCheckForUpdate, showWorkshop = showWorkshop, + showLaunchOptions = launchOptions.size >= 2, + launchOptions = launchOptions, + selectedLaunchOption = selectedLaunchOption, areSteamActionsEnabled = areSteamActionsEnabled, onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, + onSelectLaunchOption = onSelectLaunchOption, ) } @@ -803,12 +813,17 @@ private fun SourceTag( showVerifyFiles: Boolean = true, showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, + showLaunchOptions: Boolean = false, + launchOptions: List = emptyList(), + selectedLaunchOption: StoreLaunchOptionItem? = null, areSteamActionsEnabled: Boolean = true, onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, + onSelectLaunchOption: (StoreLaunchOptionItem) -> Unit = {}, ) { var menuOpen by remember { mutableStateOf(false) } + var launchOptionsPage by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableStateOf(0) } Box { Surface( @@ -818,7 +833,16 @@ private fun SourceTag( modifier = Modifier .onSizeChanged { anchorHeightPx = it.height } - .then(if (menuEnabled) Modifier.clickable { menuOpen = true } else Modifier), + .then( + if (menuEnabled) { + Modifier.clickable { + launchOptionsPage = false + menuOpen = true + } + } else { + Modifier + }, + ), ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), @@ -853,29 +877,62 @@ private fun SourceTag( val gapPx = with(LocalDensity.current) { 6.dp.roundToPx() } LaunchSourceActionPopup( expanded = menuOpen, - onDismissRequest = { menuOpen = false }, + onDismissRequest = { + menuOpen = false + launchOptionsPage = false + }, offset = IntOffset(0, anchorHeightPx + gapPx), ) { - if (showVerifyFiles) { - LaunchSourceMenuItem( - icon = Icons.AutoMirrored.Outlined.FactCheck, - label = stringResource(R.string.store_game_verify_files), - enabled = areSteamActionsEnabled, - ) { menuOpen = false; onVerifyFiles() } - } - if (showCheckForUpdate) { - LaunchSourceMenuItem( - icon = Icons.Outlined.Refresh, - label = stringResource(R.string.store_game_check_for_update), - enabled = areSteamActionsEnabled, - ) { menuOpen = false; onCheckForUpdate() } - } - if (showWorkshop) { + if (launchOptionsPage) { LaunchSourceMenuItem( - icon = Icons.Outlined.Construction, - label = stringResource(R.string.store_game_workshop), - enabled = areSteamActionsEnabled, - ) { menuOpen = false; onWorkshop() } + icon = Icons.AutoMirrored.Outlined.ArrowBack, + label = stringResource(R.string.store_game_launch_options), + ) { launchOptionsPage = false } + HorizontalDivider( + color = Color.White.copy(alpha = 0.16f), + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 12.dp), + ) + launchOptions.forEach { option -> + LaunchOptionRow( + label = option.label, + selected = option == selectedLaunchOption, + enabled = areSteamActionsEnabled, + ) { + menuOpen = false + launchOptionsPage = false + onSelectLaunchOption(option) + } + } + } else { + if (showVerifyFiles) { + LaunchSourceMenuItem( + icon = Icons.AutoMirrored.Outlined.FactCheck, + label = stringResource(R.string.store_game_verify_files), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onVerifyFiles() } + } + if (showCheckForUpdate) { + LaunchSourceMenuItem( + icon = Icons.Outlined.Refresh, + label = stringResource(R.string.store_game_check_for_update), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onCheckForUpdate() } + } + if (showWorkshop) { + LaunchSourceMenuItem( + icon = Icons.Outlined.Construction, + label = stringResource(R.string.store_game_workshop), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onWorkshop() } + } + if (showLaunchOptions) { + LaunchSourceMenuItem( + icon = Icons.Outlined.RocketLaunch, + label = stringResource(R.string.store_game_launch_options), + enabled = areSteamActionsEnabled, + ) { launchOptionsPage = true } + } } } } @@ -964,6 +1021,44 @@ private fun LaunchSourceMenuItem( } } +@Composable +private fun LaunchOptionRow( + label: String, + selected: Boolean, + enabled: Boolean = true, + onClick: () -> Unit, +) { + val contentColor = if (enabled) Color.White else Color.White.copy(alpha = 0.45f) + Row( + modifier = + Modifier + .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) + .padding(start = 14.dp, end = 14.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box(Modifier.size(16.dp), contentAlignment = Alignment.Center) { + if (selected) { + Icon( + Icons.Outlined.Check, + contentDescription = null, + tint = LaunchAccent, + modifier = Modifier.size(16.dp), + ) + } + } + Text( + label, + color = if (selected) LaunchAccent else contentColor, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.widthIn(max = 280.dp), + ) + } +} + @Composable private fun GameStatChip( icon: ImageVector, diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index b868fae39..2a22fafb1 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -217,6 +217,7 @@ import com.winlator.cmod.shared.theme.WinNativeTheme import dagger.hilt.android.AndroidEntryPoint import dagger.Lazy import com.winlator.cmod.feature.stores.steam.enums.EPersonaState +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -4585,6 +4586,28 @@ class UnifiedActivity : val isEpic = app.id >= 2000000000 val isGog = gogGame != null val epicId = if (isEpic) app.id - 2000000000 else 0 + val isSteamLibraryGame = !isCustom && !isEpic && !isGog + + var launchOptions by remember(app.id) { mutableStateOf>(emptyList()) } + var selectedLaunchOption by remember(app.id) { mutableStateOf(null) } + LaunchedEffect(app.id, isSteamLibraryGame) { + val steamInstalled = + isSteamLibraryGame && withContext(Dispatchers.IO) { SteamService.isAppInstalled(app.id) } + if (!steamInstalled) { + launchOptions = emptyList() + selectedLaunchOption = null + return@LaunchedEffect + } + val (options, selected) = loadSteamLaunchOptions(app.id) + launchOptions = options + selectedLaunchOption = selected + // Heal cached appinfo rows that predate LaunchInfo.arguments. + if (SteamService.refreshAppInfoFromPics(app.id)) { + val (freshOptions, freshSelected) = loadSteamLaunchOptions(app.id) + launchOptions = freshOptions + selectedLaunchOption = freshSelected + } + } val libraryDownloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState( initial = com.winlator.cmod.app.service.download.DownloadCoordinator.snapshotRecords(), @@ -5313,6 +5336,13 @@ class UnifiedActivity : (!isEpic || epicGame?.isInstalled == true) && (!isGog || gogGame?.isInstalled == true), showWorkshop = !isEpic && !isGog, + launchOptions = launchOptions, + selectedLaunchOption = selectedLaunchOption, + onSelectLaunchOption = { option -> + persistSteamLaunchOptionSelection(app.id, option, scope) { + selectedLaunchOption = it + } + }, areSteamActionsEnabled = when { isEpic -> !hasBlockingEpicDownloadForLibrary @@ -8782,6 +8812,68 @@ class UnifiedActivity : } } + /** + * Builds the Steam launch-option list (appinfo config.launch) for the STEAM + * dropdown on both game detail screens, plus the currently effective selection. + */ + private suspend fun loadSteamLaunchOptions(appId: Int): Pair, StoreLaunchOptionItem?> = + withContext(Dispatchers.IO) { + val appDir = java.io.File(SteamService.getAppDirPath(appId)) + val allOptions = + SteamService + .getWindowsLaunchInfos(appId) + .map { info -> + StoreLaunchOptionItem( + executable = info.executable, + arguments = info.arguments, + label = info.description.ifBlank { info.executable.substringAfterLast('/') }, + ) + } + // Label is part of the key: cached appinfo rows predating + // LaunchInfo.arguments have "" args, and exe+args alone would + // collapse distinct options like "Play (DX11)" / "Play (DX12)". + .distinctBy { Triple(it.executable.lowercase(), it.arguments, it.label) } + // Hide options whose exe is missing on disk, but if that empties a + // non-empty list (case-sensitivity quirks) keep the unfiltered set. + val onDisk = allOptions.filter { java.io.File(appDir, it.executable).isFile } + val options = onDisk.ifEmpty { allOptions } + val (selectedExe, selectedArgs) = SteamService.getSelectedLaunchOption(applicationContext, appId) + val selected = + options.firstOrNull { + it.executable.equals(selectedExe, ignoreCase = true) && it.arguments == selectedArgs + } ?: options.firstOrNull { it.executable.equals(selectedExe, ignoreCase = true) } + ?: options.firstOrNull() + options to selected + } + + private fun persistSteamLaunchOptionSelection( + appId: Int, + option: StoreLaunchOptionItem, + scope: CoroutineScope, + onSaved: (StoreLaunchOptionItem) -> Unit, + ) { + scope.launch(Dispatchers.IO) { + val saved = + SteamService.setSelectedLaunchOption( + applicationContext, + appId, + option.executable, + option.arguments, + ) + withContext(Dispatchers.Main) { + if (saved) { + onSaved(option) + } else { + com.winlator.cmod.shared.ui.toast.WinToast.show( + this@UnifiedActivity, + getString(R.string.store_game_launch_option_failed), + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } + } + // Game Manager Dialog @Composable fun GameManagerDialog( @@ -8874,33 +8966,15 @@ class UnifiedActivity : selectedLaunchOption = null return@LaunchedEffect } - val (options, selected) = - withContext(Dispatchers.IO) { - val appDir = java.io.File(SteamService.getAppDirPath(app.id)) - val allOptions = - SteamService - .getWindowsLaunchInfos(app.id) - .map { info -> - StoreLaunchOptionItem( - executable = info.executable, - arguments = info.arguments, - label = info.description.ifBlank { info.executable.substringAfterLast('/') }, - ) - }.distinctBy { it.executable.lowercase() to it.arguments } - // Hide options whose exe is missing on disk, but if that empties a - // non-empty list (case-sensitivity quirks) keep the unfiltered set. - val onDisk = allOptions.filter { java.io.File(appDir, it.executable).isFile } - val options = onDisk.ifEmpty { allOptions } - val (selectedExe, selectedArgs) = SteamService.getSelectedLaunchOption(context, app.id) - val selected = - options.firstOrNull { - it.executable.equals(selectedExe, ignoreCase = true) && it.arguments == selectedArgs - } ?: options.firstOrNull { it.executable.equals(selectedExe, ignoreCase = true) } - ?: options.firstOrNull() - options to selected - } + val (options, selected) = loadSteamLaunchOptions(app.id) launchOptions = options selectedLaunchOption = selected + // Heal cached appinfo rows that predate LaunchInfo.arguments. + if (SteamService.refreshAppInfoFromPics(app.id)) { + val (freshOptions, freshSelected) = loadSteamLaunchOptions(app.id) + launchOptions = freshOptions + selectedLaunchOption = freshSelected + } } val totalDownloadSize = selectedManifestSizes.downloadSize @@ -9026,26 +9100,7 @@ class UnifiedActivity : launchOptions = launchOptions, selectedLaunchOption = selectedLaunchOption, onSelectLaunchOption = { option -> - scope.launch(Dispatchers.IO) { - val saved = - SteamService.setSelectedLaunchOption( - applicationContext, - app.id, - option.executable, - option.arguments, - ) - withContext(Dispatchers.Main) { - if (saved) { - selectedLaunchOption = option - } else { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString(R.string.store_game_launch_option_failed), - android.widget.Toast.LENGTH_SHORT, - ) - } - } - } + persistSteamLaunchOptionSelection(app.id, option, scope) { selectedLaunchOption = it } }, dlcs = dlcItems, selectedDlcIds = selectedDlcIds.toSet(), diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index c054ef01b..7f2501364 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -7482,6 +7482,23 @@ class SteamService : Service() { return null } + /** + * Best-effort re-fetch + persist of one app's PICS appinfo. Used to heal + * cached rows that predate newly parsed fields (e.g. LaunchInfo.arguments). + * Returns false when offline / fetch fails; callers keep using the cached row. + */ + suspend fun refreshAppInfoFromPics(appId: Int): Boolean { + val fresh = + try { + fetchLatestSteamAppInfo(appId) + } catch (e: Exception) { + Timber.w(e, "refreshAppInfoFromPics failed for appId=$appId") + null + } ?: return false + persistLatestSteamAppInfo(appId, fresh) + return true + } + private suspend fun persistLatestSteamAppInfo( appId: Int, remoteSteamApp: SteamApp, From e9815d17784ab8b84c3c1ff4cbba8087e1978c51 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 03:17:22 +0000 Subject: [PATCH 03/25] ui(steam): Workshop-style window for the Launch Options picker The in-popup submenu rendered as an awkward full-width overlay. Replace it with a centered modal window that follows the Workshop window's design language: dimmed scrim, 560dp-max rounded Surface, header with an accent icon badge + LAUNCH OPTIONS overline + game title + count chip + close button, and a divided row list. Rows show the option label with its executable/arguments as the secondary line; tapping persists the selection and moves the check mark. - New LaunchOptionsScreen.kt mirroring WorkshopScreen's palette and layout (all vector icons; no new drawable assets needed). - Both STEAM dropdowns return to a flat menu whose "Launch Options" item opens the window (showLaunchOptions/onLaunchOptions params replace the list plumbing through the SourceTags). - LaunchOptionsDialog host added next to WorkshopDialog in both GameManagerDialog and LibraryGameDetailDialog. https://claude.ai/code/session_01EUsZxzAWCjSh8LYRS9W3vT --- app/src/main/app/shell/LaunchOptionsScreen.kt | 243 ++++++++++++++++++ .../main/app/shell/LibraryGameLaunchScreen.kt | 145 +++-------- .../main/app/shell/StoreGameDetailScreen.kt | 150 +++-------- app/src/main/app/shell/UnifiedActivity.kt | 71 ++++- 4 files changed, 371 insertions(+), 238 deletions(-) create mode 100644 app/src/main/app/shell/LaunchOptionsScreen.kt diff --git a/app/src/main/app/shell/LaunchOptionsScreen.kt b/app/src/main/app/shell/LaunchOptionsScreen.kt new file mode 100644 index 000000000..8e89cdbb2 --- /dev/null +++ b/app/src/main/app/shell/LaunchOptionsScreen.kt @@ -0,0 +1,243 @@ +package com.winlator.cmod.app.shell + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Check +import androidx.compose.material.icons.outlined.Close +import androidx.compose.material.icons.outlined.RocketLaunch +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.winlator.cmod.R + +// Palette — mirrors the Workshop window so the modal feels native. +private val LoBg = Color(0xFF12121B) +private val LoBorder = Color(0xFF2A2A3A) +private val LoAccent = Color(0xFF1A9FFF) +private val LoAccentGlow = Color(0xFF58A6FF) +private val LoTextPrimary = Color(0xFFF0F4FF) +private val LoTextSecondary = Color(0xFF93A6BC) +private val LoScrim = Color(0xFF000000) + +/** + * Steam launch-option picker — a Workshop-shaped modal window listing the + * game's appinfo `config.launch` entries. Tapping a row persists it as the + * game's default; the check mark moves to confirm and the window stays open. + * + * Stateless: data and callbacks are hoisted to the LaunchOptionsDialog wrapper. + */ +@Composable +internal fun StoreLaunchOptionsScreen( + gameTitle: String, + options: List, + selectedOption: StoreLaunchOptionItem?, + onSelect: (StoreLaunchOptionItem) -> Unit, + onClose: () -> Unit, +) { + BoxWithConstraints( + modifier = + Modifier + .fillMaxSize() + // Dim the game-detail screen behind so the modal reads as foreground. + .background(LoScrim.copy(alpha = 0.6f)) + .windowInsetsPadding(WindowInsets.navigationBars), + contentAlignment = Alignment.Center, + ) { + val dialogWidth = (maxWidth - 32.dp).coerceAtMost(560.dp) + val dialogMaxHeight = (maxHeight - 48.dp).coerceIn(220.dp, 640.dp) + Surface( + modifier = + Modifier + .widthIn(min = 320.dp, max = dialogWidth) + .fillMaxWidth() + .heightIn(max = dialogMaxHeight), + shape = RoundedCornerShape(14.dp), + color = LoBg, + border = BorderStroke(1.dp, LoBorder), + tonalElevation = 8.dp, + ) { + Column(Modifier.fillMaxWidth()) { + LaunchOptionsHeader( + gameTitle = gameTitle, + optionCount = options.size, + onClose = onClose, + ) + HorizontalDivider(color = LoBorder, thickness = 0.5.dp) + LazyColumn( + // fill = false: the window wraps short lists instead of + // stretching to the max height. + modifier = Modifier.fillMaxWidth().weight(1f, fill = false), + contentPadding = PaddingValues(vertical = 4.dp), + ) { + itemsIndexed(options) { index, option -> + LaunchOptionPickerRow( + option = option, + selected = option == selectedOption, + onClick = { onSelect(option) }, + ) + if (index < options.lastIndex) { + HorizontalDivider( + color = Color.White.copy(alpha = 0.06f), + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 14.dp), + ) + } + } + } + } + } + } +} + +@Composable +private fun LaunchOptionsHeader( + gameTitle: String, + optionCount: Int, + onClose: () -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 8.dp, top = 10.dp, bottom = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + Modifier + .size(34.dp) + .clip(RoundedCornerShape(9.dp)) + .background(LoAccent.copy(alpha = 0.16f)), + contentAlignment = Alignment.Center, + ) { + Icon( + Icons.Outlined.RocketLaunch, + contentDescription = null, + tint = LoAccentGlow, + modifier = Modifier.size(19.dp), + ) + } + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) { + Text( + stringResource(R.string.store_game_launch_options).uppercase(), + color = LoTextSecondary, + fontSize = 9.sp, + fontWeight = FontWeight.Bold, + letterSpacing = 0.9.sp, + ) + Text( + gameTitle, + style = MaterialTheme.typography.titleSmall, + color = LoTextPrimary, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Surface( + modifier = + Modifier.semantics { + contentDescription = "$optionCount launch options" + }, + color = LoAccent.copy(alpha = 0.14f), + shape = RoundedCornerShape(7.dp), + ) { + Text( + optionCount.toString(), + color = LoAccentGlow, + fontSize = 11.sp, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(horizontal = 9.dp, vertical = 3.dp), + ) + } + IconButton(onClick = onClose, modifier = Modifier.size(36.dp)) { + Icon( + Icons.Outlined.Close, + contentDescription = "Close", + tint = LoTextSecondary, + modifier = Modifier.size(20.dp), + ) + } + } +} + +@Composable +private fun LaunchOptionPickerRow( + option: StoreLaunchOptionItem, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 11.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(11.dp), + ) { + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + option.label, + color = if (selected) LoAccentGlow else LoTextPrimary, + fontSize = 13.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + buildString { + append(option.executable) + if (option.arguments.isNotBlank()) { + append(" · ") + append(option.arguments) + } + }, + color = LoTextSecondary, + fontSize = 11.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (selected) { + Icon( + Icons.Outlined.Check, + contentDescription = null, + tint = LoAccentGlow, + modifier = Modifier.size(18.dp), + ) + } + } +} diff --git a/app/src/main/app/shell/LibraryGameLaunchScreen.kt b/app/src/main/app/shell/LibraryGameLaunchScreen.kt index efba8a7ea..11f0c40b2 100644 --- a/app/src/main/app/shell/LibraryGameLaunchScreen.kt +++ b/app/src/main/app/shell/LibraryGameLaunchScreen.kt @@ -43,7 +43,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.automirrored.outlined.FactCheck import androidx.compose.material.icons.outlined.ArrowDropDown -import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.CloudSync import androidx.compose.material.icons.outlined.Construction import androidx.compose.material.icons.outlined.Delete @@ -60,7 +59,6 @@ import androidx.compose.material.icons.outlined.SportsEsports import androidx.compose.material.icons.outlined.Storage import androidx.compose.material.icons.outlined.Warning import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -136,9 +134,8 @@ internal fun LibraryGameLaunchScreen( showVerifyFiles: Boolean = true, showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, - launchOptions: List = emptyList(), - selectedLaunchOption: StoreLaunchOptionItem? = null, - onSelectLaunchOption: (StoreLaunchOptionItem) -> Unit = {}, + showLaunchOptions: Boolean = false, + onLaunchOptions: () -> Unit = {}, playEnabled: Boolean = true, playDisabledLabel: String? = null, onBack: () -> Unit, @@ -274,14 +271,12 @@ internal fun LibraryGameLaunchScreen( showVerifyFiles = showVerifyFiles, showCheckForUpdate = showCheckForUpdate, showWorkshop = showWorkshop, - showLaunchOptions = launchOptions.size >= 2, - launchOptions = launchOptions, - selectedLaunchOption = selectedLaunchOption, + showLaunchOptions = showLaunchOptions, areSteamActionsEnabled = areSteamActionsEnabled, onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, - onSelectLaunchOption = onSelectLaunchOption, + onLaunchOptions = onLaunchOptions, ) } @@ -814,16 +809,13 @@ private fun SourceTag( showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, showLaunchOptions: Boolean = false, - launchOptions: List = emptyList(), - selectedLaunchOption: StoreLaunchOptionItem? = null, areSteamActionsEnabled: Boolean = true, onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, - onSelectLaunchOption: (StoreLaunchOptionItem) -> Unit = {}, + onLaunchOptions: () -> Unit = {}, ) { var menuOpen by remember { mutableStateOf(false) } - var launchOptionsPage by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableStateOf(0) } Box { Surface( @@ -833,16 +825,7 @@ private fun SourceTag( modifier = Modifier .onSizeChanged { anchorHeightPx = it.height } - .then( - if (menuEnabled) { - Modifier.clickable { - launchOptionsPage = false - menuOpen = true - } - } else { - Modifier - }, - ), + .then(if (menuEnabled) Modifier.clickable { menuOpen = true } else Modifier), ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), @@ -877,62 +860,36 @@ private fun SourceTag( val gapPx = with(LocalDensity.current) { 6.dp.roundToPx() } LaunchSourceActionPopup( expanded = menuOpen, - onDismissRequest = { - menuOpen = false - launchOptionsPage = false - }, + onDismissRequest = { menuOpen = false }, offset = IntOffset(0, anchorHeightPx + gapPx), ) { - if (launchOptionsPage) { + if (showVerifyFiles) { LaunchSourceMenuItem( - icon = Icons.AutoMirrored.Outlined.ArrowBack, + icon = Icons.AutoMirrored.Outlined.FactCheck, + label = stringResource(R.string.store_game_verify_files), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onVerifyFiles() } + } + if (showCheckForUpdate) { + LaunchSourceMenuItem( + icon = Icons.Outlined.Refresh, + label = stringResource(R.string.store_game_check_for_update), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onCheckForUpdate() } + } + if (showWorkshop) { + LaunchSourceMenuItem( + icon = Icons.Outlined.Construction, + label = stringResource(R.string.store_game_workshop), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onWorkshop() } + } + if (showLaunchOptions) { + LaunchSourceMenuItem( + icon = Icons.Outlined.RocketLaunch, label = stringResource(R.string.store_game_launch_options), - ) { launchOptionsPage = false } - HorizontalDivider( - color = Color.White.copy(alpha = 0.16f), - thickness = 1.dp, - modifier = Modifier.padding(horizontal = 12.dp), - ) - launchOptions.forEach { option -> - LaunchOptionRow( - label = option.label, - selected = option == selectedLaunchOption, - enabled = areSteamActionsEnabled, - ) { - menuOpen = false - launchOptionsPage = false - onSelectLaunchOption(option) - } - } - } else { - if (showVerifyFiles) { - LaunchSourceMenuItem( - icon = Icons.AutoMirrored.Outlined.FactCheck, - label = stringResource(R.string.store_game_verify_files), - enabled = areSteamActionsEnabled, - ) { menuOpen = false; onVerifyFiles() } - } - if (showCheckForUpdate) { - LaunchSourceMenuItem( - icon = Icons.Outlined.Refresh, - label = stringResource(R.string.store_game_check_for_update), - enabled = areSteamActionsEnabled, - ) { menuOpen = false; onCheckForUpdate() } - } - if (showWorkshop) { - LaunchSourceMenuItem( - icon = Icons.Outlined.Construction, - label = stringResource(R.string.store_game_workshop), - enabled = areSteamActionsEnabled, - ) { menuOpen = false; onWorkshop() } - } - if (showLaunchOptions) { - LaunchSourceMenuItem( - icon = Icons.Outlined.RocketLaunch, - label = stringResource(R.string.store_game_launch_options), - enabled = areSteamActionsEnabled, - ) { launchOptionsPage = true } - } + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onLaunchOptions() } } } } @@ -1021,44 +978,6 @@ private fun LaunchSourceMenuItem( } } -@Composable -private fun LaunchOptionRow( - label: String, - selected: Boolean, - enabled: Boolean = true, - onClick: () -> Unit, -) { - val contentColor = if (enabled) Color.White else Color.White.copy(alpha = 0.45f) - Row( - modifier = - Modifier - .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) - .padding(start = 14.dp, end = 14.dp, top = 10.dp, bottom = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Box(Modifier.size(16.dp), contentAlignment = Alignment.Center) { - if (selected) { - Icon( - Icons.Outlined.Check, - contentDescription = null, - tint = LaunchAccent, - modifier = Modifier.size(16.dp), - ) - } - } - Text( - label, - color = if (selected) LaunchAccent else contentColor, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.widthIn(max = 280.dp), - ) - } -} - @Composable private fun GameStatChip( icon: ImageVector, diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt index 45d7b3fcc..3451bebab 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -48,7 +48,6 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.outlined.ArrowBack import androidx.compose.material.icons.automirrored.outlined.FactCheck import androidx.compose.material.icons.outlined.ArrowDropDown -import androidx.compose.material.icons.outlined.Check import androidx.compose.material.icons.outlined.CloudSync import androidx.compose.material.icons.outlined.Construction import androidx.compose.material.icons.outlined.Delete @@ -158,9 +157,8 @@ internal fun StoreGameDetailScreen( showWorkshop: Boolean = false, showVerifyFiles: Boolean = false, areSteamActionsEnabled: Boolean = true, - launchOptions: List = emptyList(), - selectedLaunchOption: StoreLaunchOptionItem? = null, - onSelectLaunchOption: (StoreLaunchOptionItem) -> Unit = {}, + showLaunchOptions: Boolean = false, + onLaunchOptions: () -> Unit = {}, dlcs: List = emptyList(), selectedDlcIds: Set = emptySet(), isDlcSelectionEnabled: Boolean = true, @@ -198,7 +196,7 @@ internal fun StoreGameDetailScreen( val showUpdateCta = updateCheckAvailable && isUpdateAvailable val verifyFilesAvailable = showVerifyFiles && isInstalled val workshopAvailable = showWorkshop && isInstalled - val launchOptionsAvailable = isInstalled && launchOptions.size >= 2 + val launchOptionsAvailable = showLaunchOptions && isInstalled val sourceMenuEnabled = updateCheckAvailable || verifyFilesAvailable || workshopAvailable || launchOptionsAvailable val showDlcCard = dlcs.isNotEmpty() @@ -308,8 +306,6 @@ internal fun StoreGameDetailScreen( showVerifyFiles = verifyFilesAvailable, showWorkshop = workshopAvailable, showLaunchOptions = launchOptionsAvailable, - launchOptions = launchOptions, - selectedLaunchOption = selectedLaunchOption, isCheckingForUpdate = isCheckingForUpdate, areSteamActionsEnabled = areSteamActionsEnabled, isUpdateCheckEnabled = @@ -320,7 +316,7 @@ internal fun StoreGameDetailScreen( onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, - onSelectLaunchOption = onSelectLaunchOption, + onLaunchOptions = onLaunchOptions, ) } @@ -799,18 +795,15 @@ private fun StoreSourceTag( showVerifyFiles: Boolean = false, showWorkshop: Boolean = false, showLaunchOptions: Boolean = false, - launchOptions: List = emptyList(), - selectedLaunchOption: StoreLaunchOptionItem? = null, isCheckingForUpdate: Boolean = false, areSteamActionsEnabled: Boolean = true, isUpdateCheckEnabled: Boolean = true, onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, - onSelectLaunchOption: (StoreLaunchOptionItem) -> Unit = {}, + onLaunchOptions: () -> Unit = {}, ) { var menuOpen by remember { mutableStateOf(false) } - var launchOptionsPage by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableIntStateOf(0) } Box { Surface( @@ -820,16 +813,7 @@ private fun StoreSourceTag( modifier = Modifier .onSizeChanged { anchorHeightPx = it.height } - .then( - if (menuEnabled) { - Modifier.clickable { - launchOptionsPage = false - menuOpen = true - } - } else { - Modifier - }, - ), + .then(if (menuEnabled) Modifier.clickable { menuOpen = true } else Modifier), ) { Row( modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), @@ -864,63 +848,41 @@ private fun StoreSourceTag( val gapPx = with(LocalDensity.current) { 6.dp.roundToPx() } StoreSourceActionPopup( expanded = menuOpen, - onDismissRequest = { - menuOpen = false - launchOptionsPage = false - }, + onDismissRequest = { menuOpen = false }, offset = IntOffset(0, anchorHeightPx + gapPx), ) { - if (launchOptionsPage) { + if (showVerifyFiles) { + StoreSourceMenuItem( + icon = Icons.AutoMirrored.Outlined.FactCheck, + label = stringResource(R.string.store_game_verify_files), + enabled = areSteamActionsEnabled && !isCheckingForUpdate, + ) { menuOpen = false; onVerifyFiles() } + } + if (showCheckForUpdate) { + StoreSourceMenuItem( + icon = Icons.Outlined.Refresh, + label = + if (isCheckingForUpdate) { + stringResource(R.string.store_game_checking_for_update) + } else { + stringResource(R.string.store_game_check_for_update) + }, + enabled = areSteamActionsEnabled && isUpdateCheckEnabled, + ) { menuOpen = false; onCheckForUpdate() } + } + if (showWorkshop) { StoreSourceMenuItem( - icon = Icons.AutoMirrored.Outlined.ArrowBack, + icon = Icons.Outlined.Construction, + label = stringResource(R.string.store_game_workshop), + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onWorkshop() } + } + if (showLaunchOptions) { + StoreSourceMenuItem( + icon = Icons.Outlined.RocketLaunch, label = stringResource(R.string.store_game_launch_options), - ) { launchOptionsPage = false } - StoreDlcDivider() - launchOptions.forEach { option -> - StoreLaunchOptionRow( - label = option.label, - selected = option == selectedLaunchOption, - enabled = areSteamActionsEnabled, - ) { - menuOpen = false - launchOptionsPage = false - onSelectLaunchOption(option) - } - } - } else { - if (showVerifyFiles) { - StoreSourceMenuItem( - icon = Icons.AutoMirrored.Outlined.FactCheck, - label = stringResource(R.string.store_game_verify_files), - enabled = areSteamActionsEnabled && !isCheckingForUpdate, - ) { menuOpen = false; onVerifyFiles() } - } - if (showCheckForUpdate) { - StoreSourceMenuItem( - icon = Icons.Outlined.Refresh, - label = - if (isCheckingForUpdate) { - stringResource(R.string.store_game_checking_for_update) - } else { - stringResource(R.string.store_game_check_for_update) - }, - enabled = areSteamActionsEnabled && isUpdateCheckEnabled, - ) { menuOpen = false; onCheckForUpdate() } - } - if (showWorkshop) { - StoreSourceMenuItem( - icon = Icons.Outlined.Construction, - label = stringResource(R.string.store_game_workshop), - enabled = areSteamActionsEnabled, - ) { menuOpen = false; onWorkshop() } - } - if (showLaunchOptions) { - StoreSourceMenuItem( - icon = Icons.Outlined.RocketLaunch, - label = stringResource(R.string.store_game_launch_options), - enabled = areSteamActionsEnabled, - ) { launchOptionsPage = true } - } + enabled = areSteamActionsEnabled, + ) { menuOpen = false; onLaunchOptions() } } } } @@ -1009,44 +971,6 @@ private fun StoreSourceMenuItem( } } -@Composable -private fun StoreLaunchOptionRow( - label: String, - selected: Boolean, - enabled: Boolean = true, - onClick: () -> Unit, -) { - val contentColor = if (enabled) Color.White else Color.White.copy(alpha = 0.45f) - Row( - modifier = - Modifier - .then(if (enabled) Modifier.clickable(onClick = onClick) else Modifier) - .padding(start = 14.dp, end = 14.dp, top = 10.dp, bottom = 10.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(10.dp), - ) { - Box(Modifier.size(16.dp), contentAlignment = Alignment.Center) { - if (selected) { - Icon( - Icons.Outlined.Check, - contentDescription = null, - tint = StoreAccent, - modifier = Modifier.size(16.dp), - ) - } - } - Text( - label, - color = if (selected) StoreAccent else contentColor, - fontSize = 12.sp, - fontWeight = FontWeight.SemiBold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.widthIn(max = 280.dp), - ) - } -} - @Composable private fun StoreStatChip( icon: ImageVector, diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 2a22fafb1..1e5adfb57 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -4588,6 +4588,7 @@ class UnifiedActivity : val epicId = if (isEpic) app.id - 2000000000 else 0 val isSteamLibraryGame = !isCustom && !isEpic && !isGog + var showLaunchOptionsDialog by remember(app.id) { mutableStateOf(false) } var launchOptions by remember(app.id) { mutableStateOf>(emptyList()) } var selectedLaunchOption by remember(app.id) { mutableStateOf(null) } LaunchedEffect(app.id, isSteamLibraryGame) { @@ -5336,13 +5337,8 @@ class UnifiedActivity : (!isEpic || epicGame?.isInstalled == true) && (!isGog || gogGame?.isInstalled == true), showWorkshop = !isEpic && !isGog, - launchOptions = launchOptions, - selectedLaunchOption = selectedLaunchOption, - onSelectLaunchOption = { option -> - persistSteamLaunchOptionSelection(app.id, option, scope) { - selectedLaunchOption = it - } - }, + showLaunchOptions = launchOptions.size >= 2, + onLaunchOptions = { showLaunchOptionsDialog = true }, areSteamActionsEnabled = when { isEpic -> !hasBlockingEpicDownloadForLibrary @@ -5823,6 +5819,20 @@ class UnifiedActivity : onDismissRequest = { showWorkshopDialog = false }, ) } + + if (showLaunchOptionsDialog) { + LaunchOptionsDialog( + gameTitle = app.name, + options = launchOptions, + selectedOption = selectedLaunchOption, + onSelect = { option -> + persistSteamLaunchOptionSelection(app.id, option, scope) { + selectedLaunchOption = it + } + }, + onDismissRequest = { showLaunchOptionsDialog = false }, + ) + } } } } @@ -8893,6 +8903,7 @@ class UnifiedActivity : var isCheckingForUpdate by remember(app.id) { mutableStateOf(false) } var isUpdateCheckCoolingDown by remember(app.id) { mutableStateOf(false) } var showWorkshopDialog by remember(app.id) { mutableStateOf(false) } + var showLaunchOptionsDialog by remember(app.id) { mutableStateOf(false) } var launchOptions by remember(app.id) { mutableStateOf>(emptyList()) } var selectedLaunchOption by remember(app.id) { mutableStateOf(null) } var updateInfo by remember(app.id) { mutableStateOf(null) } @@ -9097,11 +9108,8 @@ class UnifiedActivity : showWorkshop = isReallyInstalled, showVerifyFiles = isReallyInstalled, areSteamActionsEnabled = !hasBlockingSteamDownload, - launchOptions = launchOptions, - selectedLaunchOption = selectedLaunchOption, - onSelectLaunchOption = { option -> - persistSteamLaunchOptionSelection(app.id, option, scope) { selectedLaunchOption = it } - }, + showLaunchOptions = launchOptions.size >= 2, + onLaunchOptions = { showLaunchOptionsDialog = true }, dlcs = dlcItems, selectedDlcIds = selectedDlcIds.toSet(), isDlcSelectionEnabled = steamDownloadRecord == null, @@ -9240,6 +9248,45 @@ class UnifiedActivity : onDismissRequest = { showWorkshopDialog = false }, ) } + + if (showLaunchOptionsDialog) { + LaunchOptionsDialog( + gameTitle = app.name, + options = launchOptions, + selectedOption = selectedLaunchOption, + onSelect = { option -> + persistSteamLaunchOptionSelection(app.id, option, scope) { selectedLaunchOption = it } + }, + onDismissRequest = { showLaunchOptionsDialog = false }, + ) + } + } + + /** Hosts the Workshop-styled launch-option picker window over a game detail dialog. */ + @Composable + private fun LaunchOptionsDialog( + gameTitle: String, + options: List, + selectedOption: StoreLaunchOptionItem?, + onSelect: (StoreLaunchOptionItem) -> Unit, + onDismissRequest: () -> Unit, + ) { + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + StoreLaunchOptionsScreen( + gameTitle = gameTitle, + options = options, + selectedOption = selectedOption, + onSelect = onSelect, + onClose = onDismissRequest, + ) + } } @Composable From 77dbd56c8015633a1a6bee057c9243a377def915 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 10:54:46 +0000 Subject: [PATCH 04/25] review(steam): launch options cleanup pass MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Findings from a full-branch review applied: - PICS appinfo re-fetch now runs once per app per process (retried on failure) instead of on every game-detail open, and the two-phase load + refresh logic is shared by both dialogs via loadSteamLaunchOptionsRefreshing — drops a network round-trip and a duplicate shortcut scan from repeat opens. - LaunchOptionsDialog owns selection persistence (appId + onSelectionSaved), removing the duplicated persist lambda from both host sites. - XServerDisplayActivity: single combineWithSelectedLaunchArgs resolver replaces the getExtra+combine pattern repeated across the direct-launch and both ColdClient INI sites. - Steam-env stamp stores the raw effective LaunchOptions line instead of its hashCode — exact equality, no silent collision skips. - StoreLaunchOptionItem moved to LaunchOptionsScreen.kt where the picker lives. https://claude.ai/code/session_01EUsZxzAWCjSh8LYRS9W3vT --- app/src/main/app/shell/LaunchOptionsScreen.kt | 8 ++ .../main/app/shell/StoreGameDetailScreen.kt | 7 -- app/src/main/app/shell/UnifiedActivity.kt | 75 ++++++++++++------- .../display/XServerDisplayActivity.java | 24 ++++-- 4 files changed, 72 insertions(+), 42 deletions(-) diff --git a/app/src/main/app/shell/LaunchOptionsScreen.kt b/app/src/main/app/shell/LaunchOptionsScreen.kt index 8e89cdbb2..8309a381b 100644 --- a/app/src/main/app/shell/LaunchOptionsScreen.kt +++ b/app/src/main/app/shell/LaunchOptionsScreen.kt @@ -45,6 +45,14 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.winlator.cmod.R +/** A single Steam launch option (appinfo `config.launch` entry). */ +internal data class StoreLaunchOptionItem( + // Relative path, '/'-separated. + val executable: String, + val arguments: String, + val label: String, +) + // Palette — mirrors the Workshop window so the modal feels native. private val LoBg = Color(0xFF12121B) private val LoBorder = Color(0xFF2A2A3A) diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt index 3451bebab..f936b84a4 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -114,13 +114,6 @@ internal data class StoreDlcItem( val isInstalled: Boolean = false, ) -internal data class StoreLaunchOptionItem( - // Relative path, '/'-separated (appinfo config.launch entry). - val executable: String, - val arguments: String, - val label: String, -) - private val StoreBlack = Color.Black private val StoreCard = Color(0xFF12121B) private val StoreAccent = Color(0xFF1A9FFF) diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 1e5adfb57..6847a6622 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -4599,14 +4599,9 @@ class UnifiedActivity : selectedLaunchOption = null return@LaunchedEffect } - val (options, selected) = loadSteamLaunchOptions(app.id) - launchOptions = options - selectedLaunchOption = selected - // Heal cached appinfo rows that predate LaunchInfo.arguments. - if (SteamService.refreshAppInfoFromPics(app.id)) { - val (freshOptions, freshSelected) = loadSteamLaunchOptions(app.id) - launchOptions = freshOptions - selectedLaunchOption = freshSelected + loadSteamLaunchOptionsRefreshing(app.id) { options, selected -> + launchOptions = options + selectedLaunchOption = selected } } @@ -5822,14 +5817,11 @@ class UnifiedActivity : if (showLaunchOptionsDialog) { LaunchOptionsDialog( + appId = app.id, gameTitle = app.name, options = launchOptions, selectedOption = selectedLaunchOption, - onSelect = { option -> - persistSteamLaunchOptionSelection(app.id, option, scope) { - selectedLaunchOption = it - } - }, + onSelectionSaved = { selectedLaunchOption = it }, onDismissRequest = { showLaunchOptionsDialog = false }, ) } @@ -8822,6 +8814,11 @@ class UnifiedActivity : } } + // Apps whose appinfo was already re-fetched this process (see + // loadSteamLaunchOptionsRefreshing) — avoids a PICS round-trip on every + // game-detail open. + private val launchOptionsRefreshedApps = java.util.Collections.synchronizedSet(mutableSetOf()) + /** * Builds the Steam launch-option list (appinfo config.launch) for the STEAM * dropdown on both game detail screens, plus the currently effective selection. @@ -8856,6 +8853,28 @@ class UnifiedActivity : options to selected } + /** + * Loads launch options, then — once per app per process — re-fetches the + * app's PICS appinfo to heal cached rows that predate LaunchInfo.arguments + * and re-applies the fresh list. [apply] runs on the caller's context. + */ + private suspend fun loadSteamLaunchOptionsRefreshing( + appId: Int, + apply: (List, StoreLaunchOptionItem?) -> Unit, + ) { + val (options, selected) = loadSteamLaunchOptions(appId) + apply(options, selected) + if (launchOptionsRefreshedApps.add(appId)) { + if (SteamService.refreshAppInfoFromPics(appId)) { + val (fresh, freshSelected) = loadSteamLaunchOptions(appId) + apply(fresh, freshSelected) + } else { + // Offline or fetch failed — allow a retry on the next open. + launchOptionsRefreshedApps.remove(appId) + } + } + } + private fun persistSteamLaunchOptionSelection( appId: Int, option: StoreLaunchOptionItem, @@ -8977,14 +8996,9 @@ class UnifiedActivity : selectedLaunchOption = null return@LaunchedEffect } - val (options, selected) = loadSteamLaunchOptions(app.id) - launchOptions = options - selectedLaunchOption = selected - // Heal cached appinfo rows that predate LaunchInfo.arguments. - if (SteamService.refreshAppInfoFromPics(app.id)) { - val (freshOptions, freshSelected) = loadSteamLaunchOptions(app.id) - launchOptions = freshOptions - selectedLaunchOption = freshSelected + loadSteamLaunchOptionsRefreshing(app.id) { options, selected -> + launchOptions = options + selectedLaunchOption = selected } } @@ -9251,26 +9265,31 @@ class UnifiedActivity : if (showLaunchOptionsDialog) { LaunchOptionsDialog( + appId = app.id, gameTitle = app.name, options = launchOptions, selectedOption = selectedLaunchOption, - onSelect = { option -> - persistSteamLaunchOptionSelection(app.id, option, scope) { selectedLaunchOption = it } - }, + onSelectionSaved = { selectedLaunchOption = it }, onDismissRequest = { showLaunchOptionsDialog = false }, ) } } - /** Hosts the Workshop-styled launch-option picker window over a game detail dialog. */ + /** + * Hosts the Workshop-styled launch-option picker window over a game detail + * dialog. Selecting a row persists it as the game's default and reports the + * saved option through [onSelectionSaved]. + */ @Composable private fun LaunchOptionsDialog( + appId: Int, gameTitle: String, options: List, selectedOption: StoreLaunchOptionItem?, - onSelect: (StoreLaunchOptionItem) -> Unit, + onSelectionSaved: (StoreLaunchOptionItem) -> Unit, onDismissRequest: () -> Unit, ) { + val scope = rememberCoroutineScope() Dialog( onDismissRequest = onDismissRequest, properties = @@ -9283,7 +9302,9 @@ class UnifiedActivity : gameTitle = gameTitle, options = options, selectedOption = selectedOption, - onSelect = onSelect, + onSelect = { option -> + persistSteamLaunchOptionSelection(appId, option, scope, onSelectionSaved) + }, onClose = onDismissRequest, ) } diff --git a/app/src/main/runtime/display/XServerDisplayActivity.java b/app/src/main/runtime/display/XServerDisplayActivity.java index b241e0b6f..60c7dbb42 100644 --- a/app/src/main/runtime/display/XServerDisplayActivity.java +++ b/app/src/main/runtime/display/XServerDisplayActivity.java @@ -7493,9 +7493,8 @@ private String getWineStartCommand(GuestProgramLauncherComponent launcherCompone int appId = Integer.parseInt(shortcut.getExtra("app_id")); // Reset per launch; set below once the launch exe is resolved. wnSteamDirectExeOverride = false; - String steamExtraArgs = SteamUtils.combineSteamLaunchArgs( - shortcut.getExtra("launch_exe_args"), - shortcut.getSettingExtra("execArgs", container.getExecArgs())); + String steamExtraArgs = + combineWithSelectedLaunchArgs(shortcut.getSettingExtra("execArgs", container.getExecArgs())); steamExtraArgs = !steamExtraArgs.isEmpty() ? " " + steamExtraArgs : ""; boolean useColdClient = parseBoolean(getShortcutSetting("useColdClient", container.isUseColdClient() ? "1" : "0")); @@ -7882,8 +7881,7 @@ private void writeColdClientIniDirect(int appId, String gameDirName, String rela } String perGameExecArgs = shortcut != null ? shortcut.getSettingExtra("execArgs", container.getExecArgs()) : container.getExecArgs(); - String exeCommandLine = SteamUtils.combineSteamLaunchArgs( - shortcut != null ? shortcut.getExtra("launch_exe_args") : "", perGameExecArgs); + String exeCommandLine = combineWithSelectedLaunchArgs(perGameExecArgs); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); @@ -7959,8 +7957,7 @@ private void writeColdClientIniForLaunch(int appId, String gameInstallPath, Stri } String perGameExecArgs = shortcut != null ? shortcut.getSettingExtra("execArgs", container.getExecArgs()) : container.getExecArgs(); - String exeCommandLine = SteamUtils.combineSteamLaunchArgs( - shortcut != null ? shortcut.getExtra("launch_exe_args") : "", perGameExecArgs); + String exeCommandLine = combineWithSelectedLaunchArgs(perGameExecArgs); String iniContent = buildColdClientIni(appId, exePath, exeRunDir, exeCommandLine, runtimePatcher); @@ -8085,6 +8082,16 @@ private static String exeBaseName(String path) { return normalized.trim(); } + /** + * The selected Steam launch option's own arguments (shortcut extra + * {@code launch_exe_args}) merged with the given custom args — the single + * resolver every Steam launch channel uses for its effective command line. + */ + private String combineWithSelectedLaunchArgs(String customArgs) { + return SteamUtils.combineSteamLaunchArgs( + shortcut != null ? shortcut.getExtra("launch_exe_args") : "", customArgs); + } + private String resolveRelativeGameExe(int appId, String gameInstPath) { // Per-game launch_exe_path wins over the shared container cache. String shortcutExePath = resolveShortcutSteamExecutablePath(gameInstPath); @@ -9643,10 +9650,11 @@ private void setupSteamEnvironment(int appId, File gameDir) { String selectedLaunchArgs = shortcut != null ? shortcut.getExtra("launch_exe_args") : ""; // The effective LaunchOptions line is part of the stamp so a changed // launch-option selection (or custom args) re-runs the localconfig edit. + // Raw string, not a hash: equality must be exact (no collisions). String effectiveLaunchOptions = SteamUtils.combineSteamLaunchArgs(selectedLaunchArgs, container.getExecArgs()); String expectedStamp = "v2|" + appId + "|" + steamUserDataId - + "|" + effectiveLaunchOptions.hashCode(); + + "|" + effectiveLaunchOptions; String existingStamp = steamEnvStamp.exists() ? FileUtils.readString(steamEnvStamp).trim() : ""; boolean steamEnvWarm = expectedStamp.equals(existingStamp); From 2d5c920012fed500a78f371c2517a52b008eb45f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 11:55:08 +0000 Subject: [PATCH 05/25] feat(steam): beta branch selector in the STEAM dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Beta Branch" menu item to the STEAM ▾ dropdown on both game detail screens (StoreGameDetailScreen / LibraryGameLaunchScreen). Tapping it opens a Workshop-styled picker (BetaBranchScreen.kt) listing the game's PICS branches with the current one check-marked. Password-protected branches are shown disabled (no beta-password flow exists in this app). • BetaBranchScreen.kt — new Workshop-shaped modal; mirrors the structure of LaunchOptionsScreen.kt with AltRoute icon and per-file palette constants. • Menu item hidden when the game has only one branch (public), mirroring the >= 2 gate used by Launch Options. • Persistence: SteamService.setSelectedBetaBranch() stores the choice as the "selectedBranch" shortcut extra (blank = public/default); SteamService.resolveSelectedBetaName() refactored to reuse findSteamShortcut() instead of its own loadShortcuts() scan. • Downstream already honors the selection (resolveDepotManifestInfo, setAppCurrentBeta, ACF buildId); after a successful save the dialog calls startUpdateCheck() so the new branch's build downloads. • SteamUtils.writeCompleteSettingsDir: configs.app.ini [app::general] now writes is_beta_branch/branch_name from the actual selected branch instead of always hardcoding public, so gbe_fork's GetCurrentBetaName() / GetAppBuildId() return correct values. branches.json already lists all branches correctly — no change needed. • Appinfo refresh guard generalised: launchOptionsRefreshedApps renamed to appinfoRefreshedApps so one PICS round-trip per process feeds both the Launch Options and Beta Branch pickers. https://claude.ai/code/session_01EJFSZYUPkR1Cwer6CC2cNa --- app/src/main/app/shell/BetaBranchScreen.kt | 261 ++++++++++++++++++ .../main/app/shell/LibraryGameLaunchScreen.kt | 14 + .../main/app/shell/StoreGameDetailScreen.kt | 18 +- app/src/main/app/shell/UnifiedActivity.kt | 148 +++++++++- .../stores/steam/service/SteamService.kt | 32 ++- .../feature/stores/steam/utils/SteamUtils.kt | 9 +- app/src/main/res/values/strings.xml | 2 + 7 files changed, 467 insertions(+), 17 deletions(-) create mode 100644 app/src/main/app/shell/BetaBranchScreen.kt diff --git a/app/src/main/app/shell/BetaBranchScreen.kt b/app/src/main/app/shell/BetaBranchScreen.kt new file mode 100644 index 000000000..51d9d953a --- /dev/null +++ b/app/src/main/app/shell/BetaBranchScreen.kt @@ -0,0 +1,261 @@ +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.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 + * and triggers the update flow. 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 = { if (!branch.pwdRequired) 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 == "public") "${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 = 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/LibraryGameLaunchScreen.kt b/app/src/main/app/shell/LibraryGameLaunchScreen.kt index 11f0c40b2..11b0dff20 100644 --- a/app/src/main/app/shell/LibraryGameLaunchScreen.kt +++ b/app/src/main/app/shell/LibraryGameLaunchScreen.kt @@ -49,6 +49,7 @@ import androidx.compose.material.icons.outlined.Delete import androidx.compose.material.icons.outlined.DesktopWindows import androidx.compose.material.icons.outlined.History import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.AltRoute import androidx.compose.material.icons.outlined.RocketLaunch import androidx.compose.material.icons.outlined.Home import androidx.compose.material.icons.outlined.PlayArrow @@ -136,6 +137,8 @@ internal fun LibraryGameLaunchScreen( showWorkshop: Boolean = true, showLaunchOptions: Boolean = false, onLaunchOptions: () -> Unit = {}, + showBetaBranches: Boolean = false, + onBetaBranches: () -> Unit = {}, playEnabled: Boolean = true, playDisabledLabel: String? = null, onBack: () -> Unit, @@ -272,11 +275,13 @@ internal fun LibraryGameLaunchScreen( showCheckForUpdate = showCheckForUpdate, showWorkshop = showWorkshop, showLaunchOptions = showLaunchOptions, + showBetaBranches = showBetaBranches, areSteamActionsEnabled = areSteamActionsEnabled, onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, onLaunchOptions = onLaunchOptions, + onBetaBranches = onBetaBranches, ) } @@ -809,11 +814,13 @@ private fun SourceTag( showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, showLaunchOptions: Boolean = false, + showBetaBranches: Boolean = false, areSteamActionsEnabled: Boolean = true, onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, onLaunchOptions: () -> Unit = {}, + onBetaBranches: () -> Unit = {}, ) { var menuOpen by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableStateOf(0) } @@ -891,6 +898,13 @@ private fun SourceTag( enabled = areSteamActionsEnabled, ) { menuOpen = false; onLaunchOptions() } } + if (showBetaBranches) { + 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 f936b84a4..789ef5f37 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -57,6 +57,7 @@ import androidx.compose.material.icons.outlined.ExpandMore import androidx.compose.material.icons.outlined.Extension import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.Refresh +import androidx.compose.material.icons.outlined.AltRoute import androidx.compose.material.icons.outlined.RocketLaunch import androidx.compose.material.icons.outlined.SportsEsports import androidx.compose.material.icons.outlined.Storage @@ -152,6 +153,8 @@ internal fun StoreGameDetailScreen( areSteamActionsEnabled: Boolean = true, showLaunchOptions: Boolean = false, onLaunchOptions: () -> Unit = {}, + showBetaBranches: Boolean = false, + onBetaBranches: () -> Unit = {}, dlcs: List = emptyList(), selectedDlcIds: Set = emptySet(), isDlcSelectionEnabled: Boolean = true, @@ -190,8 +193,10 @@ internal fun StoreGameDetailScreen( val verifyFilesAvailable = showVerifyFiles && isInstalled val workshopAvailable = showWorkshop && isInstalled val launchOptionsAvailable = showLaunchOptions && isInstalled + val betaBranchesAvailable = showBetaBranches && isInstalled val sourceMenuEnabled = - updateCheckAvailable || verifyFilesAvailable || workshopAvailable || launchOptionsAvailable + updateCheckAvailable || verifyFilesAvailable || workshopAvailable || + launchOptionsAvailable || betaBranchesAvailable val showDlcCard = dlcs.isNotEmpty() val showActionColumn = showDownloadCta || showUpdateCta || @@ -299,6 +304,7 @@ internal fun StoreGameDetailScreen( showVerifyFiles = verifyFilesAvailable, showWorkshop = workshopAvailable, showLaunchOptions = launchOptionsAvailable, + showBetaBranches = betaBranchesAvailable, isCheckingForUpdate = isCheckingForUpdate, areSteamActionsEnabled = areSteamActionsEnabled, isUpdateCheckEnabled = @@ -310,6 +316,7 @@ internal fun StoreGameDetailScreen( onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, onLaunchOptions = onLaunchOptions, + onBetaBranches = onBetaBranches, ) } @@ -788,6 +795,7 @@ private fun StoreSourceTag( showVerifyFiles: Boolean = false, showWorkshop: Boolean = false, showLaunchOptions: Boolean = false, + showBetaBranches: Boolean = false, isCheckingForUpdate: Boolean = false, areSteamActionsEnabled: Boolean = true, isUpdateCheckEnabled: Boolean = true, @@ -795,6 +803,7 @@ private fun StoreSourceTag( onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, onLaunchOptions: () -> Unit = {}, + onBetaBranches: () -> Unit = {}, ) { var menuOpen by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableIntStateOf(0) } @@ -877,6 +886,13 @@ private fun StoreSourceTag( enabled = areSteamActionsEnabled, ) { menuOpen = false; onLaunchOptions() } } + if (showBetaBranches) { + StoreSourceMenuItem( + 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/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 6847a6622..6a6611685 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -4591,18 +4591,27 @@ class UnifiedActivity : 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 } loadSteamLaunchOptionsRefreshing(app.id) { options, selected -> launchOptions = options selectedLaunchOption = selected } + loadSteamBetaBranchesRefreshing(app.id) { branches, selected -> + betaBranches = branches + selectedBetaBranch = selected + } } val libraryDownloadRecords by com.winlator.cmod.app.service.download.DownloadCoordinator.records.collectAsState( @@ -5334,6 +5343,8 @@ class UnifiedActivity : showWorkshop = !isEpic && !isGog, showLaunchOptions = launchOptions.size >= 2, onLaunchOptions = { showLaunchOptionsDialog = true }, + showBetaBranches = betaBranches.size >= 2, + onBetaBranches = { showBetaBranchesDialog = true }, areSteamActionsEnabled = when { isEpic -> !hasBlockingEpicDownloadForLibrary @@ -5825,6 +5836,18 @@ class UnifiedActivity : onDismissRequest = { showLaunchOptionsDialog = false }, ) } + + if (showBetaBranchesDialog) { + BetaBranchesDialog( + appId = app.id, + gameTitle = app.name, + branches = betaBranches, + selectedBranch = selectedBetaBranch, + onSelectionSaved = { selectedBetaBranch = it }, + onDismissRequest = { showBetaBranchesDialog = false }, + onStartUpdate = { startUpdateCheck(app.id, app.name) }, + ) + } } } } @@ -8815,9 +8838,9 @@ class UnifiedActivity : } // Apps whose appinfo was already re-fetched this process (see - // loadSteamLaunchOptionsRefreshing) — avoids a PICS round-trip on every - // game-detail open. - private val launchOptionsRefreshedApps = java.util.Collections.synchronizedSet(mutableSetOf()) + // loadSteamLaunchOptionsRefreshing / loadSteamBetaBranchesRefreshing) — + // 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 @@ -8864,13 +8887,13 @@ class UnifiedActivity : ) { val (options, selected) = loadSteamLaunchOptions(appId) apply(options, selected) - if (launchOptionsRefreshedApps.add(appId)) { + if (appinfoRefreshedApps.add(appId)) { if (SteamService.refreshAppInfoFromPics(appId)) { val (fresh, freshSelected) = loadSteamLaunchOptions(appId) apply(fresh, freshSelected) } else { // Offline or fetch failed — allow a retry on the next open. - launchOptionsRefreshedApps.remove(appId) + appinfoRefreshedApps.remove(appId) } } } @@ -8903,6 +8926,63 @@ class UnifiedActivity : } } + 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 != "public" }, { it.name })) + .orEmpty() + val selectedName = SteamService.resolveSelectedBetaName(appId).ifBlank { "public" } + val selected = + branches.firstOrNull { it.name == selectedName } + ?: branches.firstOrNull { it.name == "public" } + branches to selected + } + + private suspend fun loadSteamBetaBranchesRefreshing( + appId: Int, + apply: (List, StoreBetaBranchItem?) -> Unit, + ) { + val (branches, selected) = loadSteamBetaBranches(appId) + apply(branches, selected) + if (appinfoRefreshedApps.add(appId)) { + if (SteamService.refreshAppInfoFromPics(appId)) { + val (fresh, freshSelected) = loadSteamBetaBranches(appId) + apply(fresh, freshSelected) + } else { + appinfoRefreshedApps.remove(appId) + } + } + } + + private fun persistSteamBetaBranchSelection( + appId: Int, + item: StoreBetaBranchItem, + scope: CoroutineScope, + onSaved: (StoreBetaBranchItem) -> Unit, + startUpdate: () -> Unit, + ) { + scope.launch(Dispatchers.IO) { + val branchName = if (item.name == "public") "" 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( @@ -8925,6 +9005,9 @@ class UnifiedActivity : 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) } 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( @@ -8994,12 +9077,18 @@ class UnifiedActivity : if (installed != true) { launchOptions = emptyList() selectedLaunchOption = null + betaBranches = emptyList() + selectedBetaBranch = null return@LaunchedEffect } loadSteamLaunchOptionsRefreshing(app.id) { options, selected -> launchOptions = options selectedLaunchOption = selected } + loadSteamBetaBranchesRefreshing(app.id) { branches, selected -> + betaBranches = branches + selectedBetaBranch = selected + } } val totalDownloadSize = selectedManifestSizes.downloadSize @@ -9124,6 +9213,8 @@ class UnifiedActivity : areSteamActionsEnabled = !hasBlockingSteamDownload, showLaunchOptions = launchOptions.size >= 2, onLaunchOptions = { showLaunchOptionsDialog = true }, + showBetaBranches = betaBranches.size >= 2, + onBetaBranches = { showBetaBranchesDialog = true }, dlcs = dlcItems, selectedDlcIds = selectedDlcIds.toSet(), isDlcSelectionEnabled = steamDownloadRecord == null, @@ -9273,6 +9364,18 @@ class UnifiedActivity : onDismissRequest = { showLaunchOptionsDialog = false }, ) } + + if (showBetaBranchesDialog) { + BetaBranchesDialog( + appId = app.id, + gameTitle = app.name, + branches = betaBranches, + selectedBranch = selectedBetaBranch, + onSelectionSaved = { selectedBetaBranch = it }, + onDismissRequest = { showBetaBranchesDialog = false }, + onStartUpdate = { startUpdateCheck(app.id, app.name) }, + ) + } } /** @@ -9310,6 +9413,41 @@ class UnifiedActivity : } } + /** + * Hosts the Workshop-styled beta-branch picker window over a game detail + * dialog. Selecting an unlocked row persists it and triggers [onStartUpdate]. + */ + @Composable + private fun BetaBranchesDialog( + appId: Int, + gameTitle: String, + branches: List, + selectedBranch: StoreBetaBranchItem?, + onSelectionSaved: (StoreBetaBranchItem) -> Unit, + onDismissRequest: () -> Unit, + onStartUpdate: () -> Unit, + ) { + val scope = rememberCoroutineScope() + Dialog( + onDismissRequest = onDismissRequest, + properties = + DialogProperties( + usePlatformDefaultWidth = false, + decorFitsSystemWindows = false, + ), + ) { + StoreBetaBranchScreen( + gameTitle = gameTitle, + branches = branches, + selectedBranch = selectedBranch, + onSelect = { item -> + persistSteamBetaBranchSelection(appId, item, scope, onSelectionSaved, onStartUpdate) + }, + onClose = onDismissRequest, + ) + } + } + @Composable private fun WorkshopDialog( appId: Int, diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 7f2501364..1ef3ce377 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -2362,6 +2362,30 @@ class SteamService : Service() { return exe.replace('\\', '/') to shortcut?.getExtra("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, + ): 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 + } + shortcut.putExtra("selectedBranch", branchName.ifBlank { null }) + shortcut.saveData() + return true + } + suspend fun deleteApp(appId: Int): Boolean = withContext(Dispatchers.IO) { val appDirPath = getAppDirPath(appId) @@ -5499,13 +5523,7 @@ 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 - } - "" + findSteamShortcut(svc, appId)?.getExtra("selectedBranch").orEmpty().trim() }.getOrElse { "" } } diff --git a/app/src/main/feature/stores/steam/utils/SteamUtils.kt b/app/src/main/feature/stores/steam/utils/SteamUtils.kt index acde7feec..38ec9d926 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 == "public") 0 else 1}") + appendLine("branch_name=$selectedBranch") appendLine() appendLine("[app::dlcs]") appendLine("unlock_all=0") diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2ec31c002..23231ffe9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -118,6 +118,8 @@ Workshop Launch Options Couldn\'t save launch option + Beta Branch + Couldn\'t save beta branch Verify Files Verifying %1$s — check the Downloads tab Steam options From 354345b8916650ffb75b496585417f028abf7e4e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 13:23:07 +0000 Subject: [PATCH 06/25] fix(steam): branch selector update check/download honour selected branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startUpdateCheck was calling checkForAppUpdate(appId) without a branch, so the comparison always ran against the public manifests and reported "no updates found" even after switching branches. The private downloadApp overload (routing downloadAppForUpdate) also hardcoded branch = "public", so the download itself would have fetched the wrong manifests even if the check had passed. Fix: resolve resolveSelectedBetaName before the update check and pass it to checkForAppUpdate; resolve it again inside the private downloadApp overload instead of hardcoding "public". Fresh installs (no shortcut yet) resolve to "" → "public" unchanged. https://claude.ai/code/session_01EJFSZYUPkR1Cwer6CC2cNa --- app/src/main/app/shell/UnifiedActivity.kt | 5 ++++- app/src/main/feature/stores/steam/service/SteamService.kt | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 6a6611685..411036569 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -372,7 +372,10 @@ class UnifiedActivity : lifecycleScope.launch { val result = runCatching { - withContext(Dispatchers.IO) { SteamService.checkForAppUpdate(appId) } + withContext(Dispatchers.IO) { + val branch = SteamService.resolveSelectedBetaName(appId).ifBlank { "public" } + SteamService.checkForAppUpdate(appId, branch) + } }.getOrNull() try { when { diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 1ef3ce377..6bc2052cf 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -2713,7 +2713,7 @@ class SteamService : Service() { appId = appId, downloadableDepots = downloadableDepots, userSelectedDlcAppIds = effectiveDlcAppIds, - branch = "public", + branch = resolveSelectedBetaName(appId).ifBlank { "public" }, includeInstalledDepots = includeInstalledDepots, enableVerify = enableVerify, allowPersistedProgress = allowPersistedProgress, From 98b971561efecba2200e05dcf949328f8e0f4928 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 16:24:11 +0000 Subject: [PATCH 07/25] =?UTF-8?q?fix(steam):=20lightweight=20selectedBranc?= =?UTF-8?q?h=20read=20=E2=80=94=20stop=20loadShortcuts=20in=20download=20h?= =?UTF-8?q?ot=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking install/check-update crashed after the branch-selector fix: resolveSelectedBetaName ran ContainerManager().loadShortcuts(), whose Shortcut constructor decodes full cover-art bitmaps for every shortcut, runs PE icon extraction over game exes and can rewrite .desktop files. Doing that on every download/update click spikes the heap (OOM lands on whatever thread allocates next — runCatching can't save the app) and races the UI's own .desktop reads. resolveSelectedBetaName now parses the [Extra Data] section of the .desktop files directly — no Shortcut construction, no bitmap decode, no writes. The heavyweight findSteamShortcut path remains only in the one-off setter (setSelectedBetaBranch). Also: • checkForAppUpdate / isUpdatePending default their branch parameter to the game's selected beta, so every caller (startUpdateCheck, the store dialog's onDownloadUpdate) is branch-aware without per-site resolution; the UnifiedActivity call site is back to one line. • startUpdateCheck wraps downloadAppForUpdate in runCatching — a failed update start now shows the failure popup instead of crashing. • completeAppDownload prunes stale "{depotId}_{gid}.manifest" caches not matching depot.config — branch switches no longer accumulate old manifests, and checkForAppUpdate's cache fallback can't mistake a previously-downloaded branch for the installed one. Legacy installs without depot.config are left untouched. • BetaBranchScreen: remember() the formatted date; ktlint-safe wrapping. Known limitation (documented, native-side): the Rust depot writer does not delete game files absent from the new branch's manifest, so files removed between branches linger until a fresh install. Fixing that needs a manifest-diff pass in wn-steam-client. https://claude.ai/code/session_01EJFSZYUPkR1Cwer6CC2cNa --- app/src/main/app/shell/BetaBranchScreen.kt | 10 ++- app/src/main/app/shell/UnifiedActivity.kt | 18 +++-- .../stores/steam/service/SteamService.kt | 76 +++++++++++++++++-- 3 files changed, 90 insertions(+), 14 deletions(-) diff --git a/app/src/main/app/shell/BetaBranchScreen.kt b/app/src/main/app/shell/BetaBranchScreen.kt index 51d9d953a..c4a5bc08a 100644 --- a/app/src/main/app/shell/BetaBranchScreen.kt +++ b/app/src/main/app/shell/BetaBranchScreen.kt @@ -33,6 +33,7 @@ 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 @@ -232,9 +233,12 @@ private fun BetaBranchPickerRow( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - val dateStr = branch.timeUpdated - ?.let { SimpleDateFormat("MMM d, yyyy", Locale.US).format(it) } - ?: "—" + val dateStr = + remember(branch.timeUpdated) { + branch.timeUpdated + ?.let { SimpleDateFormat("MMM d, yyyy", Locale.US).format(it) } + ?: "—" + } Text( "build ${branch.buildId} · $dateStr", color = BbTextSecondary, diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 411036569..354049412 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -372,10 +372,8 @@ class UnifiedActivity : lifecycleScope.launch { val result = runCatching { - withContext(Dispatchers.IO) { - val branch = SteamService.resolveSelectedBetaName(appId).ifBlank { "public" } - SteamService.checkForAppUpdate(appId, branch) - } + // checkForAppUpdate defaults to the game's selected beta branch. + withContext(Dispatchers.IO) { SteamService.checkForAppUpdate(appId) } }.getOrNull() try { when { @@ -386,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( diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 6bc2052cf..2230a0734 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -4538,6 +4538,28 @@ class SteamService : Service() { runCatching { MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DRM_PATCHED) } runCatching { MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DRM_UNPACK_CHECKED) } + // Prune stale "{depotId}_{gid}.manifest" caches: after a branch + // switch or ordinary update the previous build's manifests linger, + // wasting disk and — when depot.config is missing an entry — + // letting checkForAppUpdate's cache fallback mistake an old build + // for installed. Keep only what depot.config says is current; skip + // legacy installs with no depot.config, whose fallback needs them. + runCatching { + val installedManifests = readInstalledDepotManifestIds(appDirPath) + if (installedManifests.isNotEmpty()) { + File(appDirPath, ".DepotDownloader") + .listFiles { f -> f.isFile && f.name.endsWith(".manifest") } + ?.forEach { f -> + val parts = f.name.removeSuffix(".manifest").split('_') + val depotId = parts.getOrNull(0)?.toIntOrNull() ?: return@forEach + val gid = parts.getOrNull(1)?.toLongOrNull() ?: return@forEach + if (parts.size == 2 && installedManifests[depotId] != gid && f.delete()) { + Timber.i("Pruned stale depot manifest cache ${f.name} at $appDirPath") + } + } + } + }.onFailure { e -> Timber.w(e, "Stale manifest prune failed for $appDirPath") } + // Same reason as the runCatching above: a Room exception // here used to FAIL a fully-downloaded game with COMPLETE // marker already on disk. @@ -5522,9 +5544,53 @@ class SteamService : Service() { fun resolveSelectedBetaName(appId: Int): String { if (appId <= 0) return "" val svc = instance ?: return "" - return runCatching { - findSteamShortcut(svc, appId)?.getExtra("selectedBranch").orEmpty().trim() - }.getOrElse { "" } + return runCatching { readSelectedBranchExtra(svc, appId).trim() }.getOrElse { "" } + } + + /** + * Reads the `selectedBranch` extra straight from the Steam shortcut's + * .desktop file. Deliberately NOT findSteamShortcut/loadShortcuts: the + * Shortcut constructor decodes cover-art bitmaps, runs PE icon extraction + * and can rewrite .desktop files — far too heavy (and write-racy) for the + * download/update-check hot paths that only need one string. + */ + private fun readSelectedBranchExtra( + context: Context, + appId: Int, + ): String { + val appIdStr = appId.toString() + val homeDir = File(ImageFs.find(context).rootDir, "home") + val userDirs = + homeDir.listFiles { f -> f.isDirectory && f.name.startsWith("${ImageFs.USER}-") } + ?: return "" + for (userDir in userDirs) { + val desktopDir = File(userDir, ".wine/drive_c/users/${ImageFs.USER}/Desktop") + val files = desktopDir.listFiles { f -> f.name.endsWith(".desktop") } ?: continue + for (file in files) { + var section = "" + var gameSource = "" + var fileAppId = "" + var branch = "" + for (raw in file.readLines()) { + val line = raw.trim() + if (line.isEmpty() || line.startsWith("#")) continue + if (line.startsWith("[")) { + section = line.substringAfter("[").substringBefore("]") + continue + } + if (section != "Extra Data") continue + when (line.substringBefore("=", "")) { + "game_source" -> gameSource = line.substringAfter("=") + "app_id" -> fileAppId = line.substringAfter("=") + "selectedBranch" -> branch = line.substringAfter("=") + } + } + if (gameSource == "STEAM" && fileAppId == appIdStr && branch.isNotEmpty()) { + return branch + } + } + } + return "" } suspend fun refreshEncryptedAppTicketForLibSteamClient(appId: Int): Boolean { @@ -7385,12 +7451,12 @@ class SteamService : Service() { suspend fun isUpdatePending( appId: Int, - branch: String = "public", + branch: String = resolveSelectedBetaName(appId).ifBlank { "public" }, ): Boolean = checkForAppUpdate(appId, branch).hasUpdate suspend fun checkForAppUpdate( appId: Int, - branch: String = "public", + branch: String = resolveSelectedBetaName(appId).ifBlank { "public" }, ): SteamUpdateInfo = withContext(Dispatchers.IO) { fun SteamUpdateInfo.logged(): SteamUpdateInfo { From 5a8fd92a954e52920ac33747cbab71bcbd57d085 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 16:33:14 +0000 Subject: [PATCH 08/25] =?UTF-8?q?review(steam):=20beta=20branch=20selector?= =?UTF-8?q?=20=E2=80=94=20multi-angle=20review=20fixes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applied from a seven-angle review pass (line-scan, removed-behavior, cross-file tracing, reuse, simplification, efficiency, altitude): • "public" comparisons now case-insensitive (equals ignoreCase), matching the existing convention in branches.json / manifest resolution — covers the row label, sort order, selection fallback, blank-mapping on save, and configs.app.ini is_beta_branch. • Branch picker closes after a successful save so the update-check popup it triggers is what the user sees next, not a stale window. • Install/DLC size estimates in GameManagerDialog are sized against the selected branch (resolved once per load) — previously the cards showed public-branch sizes while the download fetched the beta. • readSelectedBranchExtra uses FileUtils.readLines (same reader as Shortcut's parser): one corrupt .desktop file no longer aborts the scan of remaining containers. • Stale-manifest prune extracted to pruneStaleDepotManifestCache next to the other cleanup helpers. • Store onInstall download start wrapped in runCatching — an exception there killed the app (plain coroutine launch, no handler). • Dropped the redundant pwdRequired guard in the picker row callback; the row-level clickable gate already enforces it. https://claude.ai/code/session_01EJFSZYUPkR1Cwer6CC2cNa --- app/src/main/app/shell/BetaBranchScreen.kt | 16 ++++-- app/src/main/app/shell/UnifiedActivity.kt | 41 +++++++++++--- .../stores/steam/service/SteamService.kt | 55 +++++++++++-------- .../feature/stores/steam/utils/SteamUtils.kt | 2 +- 4 files changed, 77 insertions(+), 37 deletions(-) diff --git a/app/src/main/app/shell/BetaBranchScreen.kt b/app/src/main/app/shell/BetaBranchScreen.kt index c4a5bc08a..82ce226c5 100644 --- a/app/src/main/app/shell/BetaBranchScreen.kt +++ b/app/src/main/app/shell/BetaBranchScreen.kt @@ -71,9 +71,10 @@ 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 - * and triggers the update flow. Password-protected branches are shown as - * disabled (no beta-password support in this app). + * 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. */ @@ -121,7 +122,7 @@ internal fun StoreBetaBranchScreen( BetaBranchPickerRow( branch = branch, selected = branch == selectedBranch, - onClick = { if (!branch.pwdRequired) onSelect(branch) }, + onClick = { onSelect(branch) }, ) if (index < branches.lastIndex) { HorizontalDivider( @@ -224,7 +225,12 @@ private fun BetaBranchPickerRow( horizontalArrangement = Arrangement.spacedBy(11.dp), ) { Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { - val displayName = if (branch.name == "public") "${branch.name} (default)" else branch.name + val displayName = + if (branch.name.equals("public", ignoreCase = true)) { + "${branch.name} (default)" + } else { + branch.name + } Text( displayName, color = if (selected) BbAccentGlow else BbTextPrimary, diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 354049412..62ec98c6d 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -8942,12 +8942,13 @@ class UnifiedActivity : ?.branches ?.values ?.map { b -> StoreBetaBranchItem(b.name, b.buildId, b.timeUpdated, b.pwdRequired) } - ?.sortedWith(compareBy({ it.name != "public" }, { it.name })) + ?.sortedWith(compareBy({ !it.name.equals("public", ignoreCase = true) }, { it.name })) .orEmpty() val selectedName = SteamService.resolveSelectedBetaName(appId).ifBlank { "public" } val selected = - branches.firstOrNull { it.name == selectedName } - ?: branches.firstOrNull { it.name == "public" } + 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 } @@ -8975,7 +8976,9 @@ class UnifiedActivity : startUpdate: () -> Unit, ) { scope.launch(Dispatchers.IO) { - val branchName = if (item.name == "public") "" else item.name + // "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) { @@ -9049,10 +9052,12 @@ class UnifiedActivity : LaunchedEffect(app.id, downloadRecords) { val loadData = withContext(Dispatchers.IO) { + // Size the same branch the download will actually fetch. + val branch = 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) @@ -9062,7 +9067,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), ) } @@ -9078,7 +9083,8 @@ class UnifiedActivity : LaunchedEffect(app.id, selectedDlcIds.toList()) { selectedManifestSizes = withContext(Dispatchers.IO) { - SteamService.getInstallableSelectedManifestSizes(app.id, selectedDlcIds.toList()) + val branch = SteamService.resolveSelectedBetaName(app.id).ifBlank { "public" } + SteamService.getInstallableSelectedManifestSizes(app.id, selectedDlcIds.toList(), branch = branch) } } @@ -9242,7 +9248,13 @@ class UnifiedActivity : val installableDlcIds = dlcItems .filter { !it.isInstalled && it.id in selectedDlcIds } .map { it.id } - SteamService.downloadApp(app.id, installableDlcIds, false, customPath) + // An exception here is an app crash (plain launch, no + // handler) — surface it as a failed start instead. + runCatching { + SteamService.downloadApp(app.id, installableDlcIds, false, customPath) + }.onFailure { e -> + Log.w("UnifiedActivity", "Steam download failed to start for appId=${app.id}", e) + } withContext(Dispatchers.Main) { onDismissRequest() } } } @@ -9450,7 +9462,18 @@ class UnifiedActivity : branches = branches, selectedBranch = selectedBranch, onSelect = { item -> - persistSteamBetaBranchSelection(appId, item, scope, onSelectionSaved, onStartUpdate) + persistSteamBetaBranchSelection( + appId = appId, + item = item, + scope = scope, + onSaved = { saved -> + onSelectionSaved(saved) + // Close the picker so the update-check flow it triggers + // is what the user sees next, not a stale window. + onDismissRequest() + }, + startUpdate = onStartUpdate, + ) }, onClose = onDismissRequest, ) diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 2230a0734..b5173abd9 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -92,6 +92,7 @@ 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.io.FileUtils import com.winlator.cmod.shared.ui.toast.WinToast import com.winlator.cmod.shared.android.NotificationHelper import com.winlator.cmod.shared.io.StorageUtils @@ -4538,27 +4539,7 @@ class SteamService : Service() { runCatching { MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DRM_PATCHED) } runCatching { MarkerUtils.removeMarker(appDirPath, Marker.STEAM_DRM_UNPACK_CHECKED) } - // Prune stale "{depotId}_{gid}.manifest" caches: after a branch - // switch or ordinary update the previous build's manifests linger, - // wasting disk and — when depot.config is missing an entry — - // letting checkForAppUpdate's cache fallback mistake an old build - // for installed. Keep only what depot.config says is current; skip - // legacy installs with no depot.config, whose fallback needs them. - runCatching { - val installedManifests = readInstalledDepotManifestIds(appDirPath) - if (installedManifests.isNotEmpty()) { - File(appDirPath, ".DepotDownloader") - .listFiles { f -> f.isFile && f.name.endsWith(".manifest") } - ?.forEach { f -> - val parts = f.name.removeSuffix(".manifest").split('_') - val depotId = parts.getOrNull(0)?.toIntOrNull() ?: return@forEach - val gid = parts.getOrNull(1)?.toLongOrNull() ?: return@forEach - if (parts.size == 2 && installedManifests[depotId] != gid && f.delete()) { - Timber.i("Pruned stale depot manifest cache ${f.name} at $appDirPath") - } - } - } - }.onFailure { e -> Timber.w(e, "Stale manifest prune failed for $appDirPath") } + pruneStaleDepotManifestCache(appDirPath) // Same reason as the runCatching above: a Room exception // here used to FAIL a fully-downloaded game with COMPLETE @@ -5571,7 +5552,10 @@ class SteamService : Service() { var gameSource = "" var fileAppId = "" var branch = "" - for (raw in file.readLines()) { + // 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("[")) { @@ -7627,6 +7611,33 @@ 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. + */ + private fun pruneStaleDepotManifestCache(appDirPath: String) { + runCatching { + val installedManifests = readInstalledDepotManifestIds(appDirPath) + if (installedManifests.isEmpty()) return + File(appDirPath, ".DepotDownloader") + .listFiles { f -> f.isFile && f.name.endsWith(".manifest") } + ?.forEach { f -> + val parts = f.name.removeSuffix(".manifest").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 && f.delete()) { + Timber.i("Pruned stale depot manifest cache ${f.name} at $appDirPath") + } + } + }.onFailure { e -> Timber.w(e, "Stale manifest prune failed for $appDirPath") } + } + private fun cleanupCancelledUpdate(appDirPath: String) { MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_IN_PROGRESS_MARKER) MarkerUtils.removeMarker(appDirPath, Marker.DOWNLOAD_COMPLETE_MARKER) diff --git a/app/src/main/feature/stores/steam/utils/SteamUtils.kt b/app/src/main/feature/stores/steam/utils/SteamUtils.kt index 38ec9d926..a854be1c7 100644 --- a/app/src/main/feature/stores/steam/utils/SteamUtils.kt +++ b/app/src/main/feature/stores/steam/utils/SteamUtils.kt @@ -1185,7 +1185,7 @@ object SteamUtils { // [app::general] — communicate the active branch to gbe_fork so // GetCurrentBetaName() and GetAppBuildId() return correct values. appendLine("[app::general]") - appendLine("is_beta_branch=${if (selectedBranch == "public") 0 else 1}") + appendLine("is_beta_branch=${if (selectedBranch.equals("public", ignoreCase = true)) 0 else 1}") appendLine("branch_name=$selectedBranch") appendLine() appendLine("[app::dlcs]") From 25b366f919fc112f7786010c265600a02c7d02ee Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 21:22:53 +0000 Subject: [PATCH 09/25] perf(steam): read launch-option selection without constructing Shortcuts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getSelectedLaunchOption ran ContainerManager().loadShortcuts() on every game-detail open. Each Shortcut constructed that way decodes the full cover-art bitmap, runs PE icon extraction over the game exe and can rewrite .desktop files — a large transient heap spike and a write race against the UI's own shortcut reads, just to fetch two strings. New readSteamShortcutExtras parses the [Extra Data] section straight from the .desktop files and returns the owning container dir; the container.executablePath fallback is read from the .container config JSON directly. The heavyweight findSteamShortcut path remains only in setSelectedLaunchOption, which must materialize the shortcut to write. https://claude.ai/code/session_01EJFSZYUPkR1Cwer6CC2cNa --- .../stores/steam/service/SteamService.kt | 71 +++++++++++++++++-- 1 file changed, 66 insertions(+), 5 deletions(-) diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index b5173abd9..1a1ed704a 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -95,6 +95,7 @@ import com.winlator.cmod.shared.android.AppTerminationHelper import com.winlator.cmod.shared.io.FileUtils import com.winlator.cmod.shared.ui.toast.WinToast import com.winlator.cmod.shared.android.NotificationHelper +import com.winlator.cmod.shared.io.FileUtils import com.winlator.cmod.shared.io.StorageUtils import dagger.hilt.android.AndroidEntryPoint import com.winlator.cmod.feature.stores.steam.enums.EDepotFileFlag @@ -2314,6 +2315,60 @@ class SteamService : Service() { it.getExtra("game_source") == "STEAM" && it.getExtra("app_id") == appId.toString() } + /** + * Lightweight read of the Steam shortcut's `[Extra Data]` section straight + * from its .desktop file, with the owning container's root dir. Deliberately + * NOT findSteamShortcut/loadShortcuts on read-only paths: the Shortcut + * constructor decodes cover-art bitmaps, runs PE icon extraction and can + * rewrite .desktop files — far too heavy (and write-racy) for hot paths + * that only need a couple of strings. Returns null when no Steam shortcut + * exists for [appId]. + */ + private fun readSteamShortcutExtras( + context: Context, + appId: Int, + ): Pair, File>? { + val appIdStr = appId.toString() + val homeDir = File(ImageFs.find(context).rootDir, "home") + val containerDirs = + homeDir.listFiles { f -> f.isDirectory && f.name.startsWith("${ImageFs.USER}-") } + ?: return null + for (containerDir in containerDirs) { + val desktopDir = File(containerDir, ".wine/drive_c/users/${ImageFs.USER}/Desktop") + val files = desktopDir.listFiles { f -> f.name.endsWith(".desktop") } ?: continue + for (file in files) { + var section = "" + val extras = mutableMapOf() + // FileUtils.readLines (also used by Shortcut's parser) returns + // what it could read on IO errors — one corrupt .desktop file + // must not abort the scan of the remaining containers. + for (raw in FileUtils.readLines(file)) { + val line = raw.trim() + if (line.isEmpty() || line.startsWith("#")) continue + if (line.startsWith("[")) { + section = line.substringAfter("[").substringBefore("]") + continue + } + if (section != "Extra Data") continue + val key = line.substringBefore("=", "") + if (key.isNotEmpty()) extras[key] = line.substringAfter("=") + } + if (extras["game_source"] == "STEAM" && extras["app_id"] == appIdStr) { + return extras to containerDir + } + } + } + return null + } + + /** Reads executablePath from a container's `.container` config without Container construction. */ + private fun readContainerExecutablePath(containerDir: File): String = + runCatching { + val configFile = File(containerDir, ".container") + if (!configFile.isFile) return "" + JSONObject(configFile.readText()).optString("executablePath", "") + }.getOrElse { "" } + /** * Persists the user's launch-option choice (an appinfo `config.launch` entry) on * the game's shortcut + container, matching resolveRelativeGameExe's priority: @@ -2349,18 +2404,24 @@ class SteamService : Service() { /** * Currently effective launch option as (executable, arguments), resolved in the - * same order the launch path uses. Call on an IO dispatcher. + * same order the launch path uses: shortcut `launch_exe_path` first, the owning + * container's executablePath as fallback, then the installed exe. Reads the + * .desktop/.container files directly (this runs on every game-detail open). + * Call on an IO dispatcher. */ fun getSelectedLaunchOption( context: Context, appId: Int, ): Pair { - val shortcut = findSteamShortcut(context, appId) + val found = runCatching { readSteamShortcutExtras(context, appId) }.getOrNull() + val extras = found?.first.orEmpty() val exe = - shortcut?.getExtra("launch_exe_path").orEmpty().ifBlank { - shortcut?.container?.executablePath.orEmpty().ifBlank { getInstalledExe(appId) } + extras["launch_exe_path"].orEmpty().ifBlank { + found?.second?.let { readContainerExecutablePath(it) }.orEmpty().ifBlank { + getInstalledExe(appId) + } } - return exe.replace('\\', '/') to shortcut?.getExtra("launch_exe_args").orEmpty() + return exe.replace('\\', '/') to extras["launch_exe_args"].orEmpty() } /** From 639570cc255ca33883997a9950a29542341cf8ab Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 21:26:56 +0000 Subject: [PATCH 10/25] fix(steam): crash-proof the store dialog's data-loading effects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Steam store tab crashed on game tap while the library detail worked. GameManagerDialog runs three data-loading LaunchedEffects the library dialog doesn't all share — and an exception inside a LaunchedEffect tears down the whole Activity. All data-loading effects in GameManagerDialog and the shared launch-options/beta-branches loader effects now degrade on failure (logged, menu items hidden / zero sizes) instead of crashing, with CancellationException rethrown so effect restarts still work. Also dedupes the merged shortcut readers: resolveSelectedBetaName now uses readSteamShortcutExtras from the launch-options branch (which gained the same lightweight .desktop reader for getSelectedLaunchOption) and the selectedBranch-specific copy is gone. Read and write paths now agree on "first STEAM shortcut for the appId". https://claude.ai/code/session_01EJFSZYUPkR1Cwer6CC2cNa --- app/src/main/app/shell/UnifiedActivity.kt | 95 ++++++++++++------- .../stores/steam/service/SteamService.kt | 53 +---------- 2 files changed, 66 insertions(+), 82 deletions(-) diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 62ec98c6d..a7750f19f 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -4613,13 +4613,19 @@ class UnifiedActivity : selectedBetaBranch = null return@LaunchedEffect } - loadSteamLaunchOptionsRefreshing(app.id) { options, selected -> - launchOptions = options - selectedLaunchOption = selected - } - loadSteamBetaBranchesRefreshing(app.id) { branches, selected -> - betaBranches = branches - selectedBetaBranch = selected + // 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) } } @@ -9050,25 +9056,39 @@ class UnifiedActivity : ) LaunchedEffect(app.id, downloadRecords) { + // An exception in this effect tears down the whole Activity — degrade + // to an empty load (logged) instead of crashing the store screen. val loadData = - withContext(Dispatchers.IO) { - // Size the same branch the download will actually fetch. - val branch = 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, branch = branch) - } - val installedDlcIds = - SteamService.getInstalledDlcDepotsOf(app.id) - .orEmpty() - .toSet() + runCatching { + withContext(Dispatchers.IO) { + // Size the same branch the download will actually fetch. + val branch = 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, branch = branch) + } + val installedDlcIds = + SteamService.getInstalledDlcDepotsOf(app.id) + .orEmpty() + .toSet() + SteamInstallLoadData( + dlcApps = selectableDlcApps, + dlcSizes = perDlcSizes, + installedDlcIds = installedDlcIds, + baseManifestSizes = SteamService.getInstallableSelectedManifestSizes(app.id, branch = branch), + installed = SteamService.isAppInstalled(app.id), + ) + } + }.getOrElse { e -> + if (e is kotlinx.coroutines.CancellationException) throw e + Log.e("UnifiedActivity", "Steam install data load failed for appId=${app.id}", e) SteamInstallLoadData( - dlcApps = selectableDlcApps, - dlcSizes = perDlcSizes, - installedDlcIds = installedDlcIds, - baseManifestSizes = SteamService.getInstallableSelectedManifestSizes(app.id, branch = branch), - installed = SteamService.isAppInstalled(app.id), + dlcApps = emptyList(), + dlcSizes = emptyMap(), + installedDlcIds = emptySet(), + baseManifestSizes = SteamService.ManifestSizes(), + installed = runCatching { withContext(Dispatchers.IO) { SteamService.isAppInstalled(app.id) } }.getOrDefault(false), ) } dlcApps = loadData.dlcApps @@ -9081,11 +9101,16 @@ class UnifiedActivity : } LaunchedEffect(app.id, selectedDlcIds.toList()) { - selectedManifestSizes = + runCatching { withContext(Dispatchers.IO) { val branch = SteamService.resolveSelectedBetaName(app.id).ifBlank { "public" } SteamService.getInstallableSelectedManifestSizes(app.id, selectedDlcIds.toList(), branch = branch) } + }.onSuccess { selectedManifestSizes = it } + .onFailure { e -> + if (e is kotlinx.coroutines.CancellationException) throw e + Log.e("UnifiedActivity", "Steam DLC size load failed for appId=${app.id}", e) + } } LaunchedEffect(app.id, installed) { @@ -9096,13 +9121,19 @@ class UnifiedActivity : selectedBetaBranch = null return@LaunchedEffect } - loadSteamLaunchOptionsRefreshing(app.id) { options, selected -> - launchOptions = options - selectedLaunchOption = selected - } - loadSteamBetaBranchesRefreshing(app.id) { branches, selected -> - betaBranches = branches - selectedBetaBranch = selected + // 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) } } diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 1a1ed704a..69d0aa473 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -5586,56 +5586,9 @@ class SteamService : Service() { fun resolveSelectedBetaName(appId: Int): String { if (appId <= 0) return "" val svc = instance ?: return "" - return runCatching { readSelectedBranchExtra(svc, appId).trim() }.getOrElse { "" } - } - - /** - * Reads the `selectedBranch` extra straight from the Steam shortcut's - * .desktop file. Deliberately NOT findSteamShortcut/loadShortcuts: the - * Shortcut constructor decodes cover-art bitmaps, runs PE icon extraction - * and can rewrite .desktop files — far too heavy (and write-racy) for the - * download/update-check hot paths that only need one string. - */ - private fun readSelectedBranchExtra( - context: Context, - appId: Int, - ): String { - val appIdStr = appId.toString() - val homeDir = File(ImageFs.find(context).rootDir, "home") - val userDirs = - homeDir.listFiles { f -> f.isDirectory && f.name.startsWith("${ImageFs.USER}-") } - ?: return "" - for (userDir in userDirs) { - val desktopDir = File(userDir, ".wine/drive_c/users/${ImageFs.USER}/Desktop") - val files = desktopDir.listFiles { f -> f.name.endsWith(".desktop") } ?: continue - for (file in files) { - var section = "" - var gameSource = "" - var fileAppId = "" - var branch = "" - // 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 - when (line.substringBefore("=", "")) { - "game_source" -> gameSource = line.substringAfter("=") - "app_id" -> fileAppId = line.substringAfter("=") - "selectedBranch" -> branch = line.substringAfter("=") - } - } - if (gameSource == "STEAM" && fileAppId == appIdStr && branch.isNotEmpty()) { - return branch - } - } - } - return "" + return runCatching { + readSteamShortcutExtras(svc, appId)?.first?.get("selectedBranch").orEmpty().trim() + }.getOrElse { "" } } suspend fun refreshEncryptedAppTicketForLibSteamClient(appId: Int): Boolean { From 2f508c01fa6a2bf268f70968ba628163194485a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 21:42:02 +0000 Subject: [PATCH 11/25] =?UTF-8?q?fix(steam):=20VerifyError=20on=20store=20?= =?UTF-8?q?game=20tap=20=E2=80=94=20shrink=20StoreGameDetailScreen=20signa?= =?UTF-8?q?ture?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The store-tab crash was never a logic bug: ART's bytecode verifier rejected StoreGameDetailScreenKt itself — VerifyError: [0x17BC] wide register v5 has type Low-half Constant/Zero/null a D8/Compose codegen defect triggered by the composable's huge parameter list (≈44 params incl. several longs + default masks). The launch-options build, two parameters smaller, verified fine on the same device; adding showBetaBranches/onBetaBranches pushed the generated default-argument handler over the edge. The class failed to load the moment the store dialog composed it — which is why it crashed instantly, for every game, store tab only, and no runCatching could help. Fix: fold each show-flag + callback pair into a single nullable callback (null = menu item hidden) in StoreGameDetailScreen, LibraryGameLaunchScreen and their source-tag menus. That returns StoreGameDetailScreen to exactly the parameter count that demonstrably passes the verifier, with a slightly cleaner API. Also fixes the PR CI compile failure: the merge had interleaved a duplicate com.winlator.cmod.shared.io.FileUtils import in SteamService.kt ("Conflicting import: name is ambiguous"). https://claude.ai/code/session_01EJFSZYUPkR1Cwer6CC2cNa --- .../main/app/shell/LibraryGameLaunchScreen.kt | 21 ++++++------ .../main/app/shell/StoreGameDetailScreen.kt | 31 +++++++++--------- app/src/main/app/shell/UnifiedActivity.kt | 32 ++++++++++++++----- .../stores/steam/service/SteamService.kt | 3 +- 4 files changed, 49 insertions(+), 38 deletions(-) diff --git a/app/src/main/app/shell/LibraryGameLaunchScreen.kt b/app/src/main/app/shell/LibraryGameLaunchScreen.kt index 11b0dff20..254abec01 100644 --- a/app/src/main/app/shell/LibraryGameLaunchScreen.kt +++ b/app/src/main/app/shell/LibraryGameLaunchScreen.kt @@ -135,10 +135,10 @@ internal fun LibraryGameLaunchScreen( showVerifyFiles: Boolean = true, showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, - showLaunchOptions: Boolean = false, - onLaunchOptions: () -> Unit = {}, - showBetaBranches: Boolean = false, - onBetaBranches: () -> Unit = {}, + // Nullable on purpose: null hides the menu item (mirrors StoreGameDetailScreen, + // whose signature size tripped ART's bytecode verifier with separate flags). + onLaunchOptions: (() -> Unit)? = null, + onBetaBranches: (() -> Unit)? = null, playEnabled: Boolean = true, playDisabledLabel: String? = null, onBack: () -> Unit, @@ -274,8 +274,6 @@ internal fun LibraryGameLaunchScreen( showVerifyFiles = showVerifyFiles, showCheckForUpdate = showCheckForUpdate, showWorkshop = showWorkshop, - showLaunchOptions = showLaunchOptions, - showBetaBranches = showBetaBranches, areSteamActionsEnabled = areSteamActionsEnabled, onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, @@ -813,14 +811,13 @@ private fun SourceTag( showVerifyFiles: Boolean = true, showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, - showLaunchOptions: Boolean = false, - showBetaBranches: Boolean = false, areSteamActionsEnabled: Boolean = true, onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, - onLaunchOptions: () -> Unit = {}, - onBetaBranches: () -> Unit = {}, + // null hides the menu item (see LibraryGameLaunchScreen's signature note). + onLaunchOptions: (() -> Unit)? = null, + onBetaBranches: (() -> Unit)? = null, ) { var menuOpen by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableStateOf(0) } @@ -891,14 +888,14 @@ private fun SourceTag( enabled = areSteamActionsEnabled, ) { menuOpen = false; onWorkshop() } } - if (showLaunchOptions) { + if (onLaunchOptions != null) { LaunchSourceMenuItem( icon = Icons.Outlined.RocketLaunch, label = stringResource(R.string.store_game_launch_options), enabled = areSteamActionsEnabled, ) { menuOpen = false; onLaunchOptions() } } - if (showBetaBranches) { + if (onBetaBranches != null) { LaunchSourceMenuItem( icon = Icons.Outlined.AltRoute, label = stringResource(R.string.store_game_beta_branch), diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt index 789ef5f37..4dacae1ad 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -151,10 +151,12 @@ internal fun StoreGameDetailScreen( showWorkshop: Boolean = false, showVerifyFiles: Boolean = false, areSteamActionsEnabled: Boolean = true, - showLaunchOptions: Boolean = false, - onLaunchOptions: () -> Unit = {}, - showBetaBranches: Boolean = false, - onBetaBranches: () -> Unit = {}, + // Nullable on purpose: null hides the menu item. Folding show+callback into + // one parameter keeps this signature at the size that still passes ART's + // bytecode verifier — at ~44 params D8 emitted a default-handler this + // device's verifier rejected (VerifyError: wide register Low-half). + onLaunchOptions: (() -> Unit)? = null, + onBetaBranches: (() -> Unit)? = null, dlcs: List = emptyList(), selectedDlcIds: Set = emptySet(), isDlcSelectionEnabled: Boolean = true, @@ -192,8 +194,8 @@ internal fun StoreGameDetailScreen( val showUpdateCta = updateCheckAvailable && isUpdateAvailable val verifyFilesAvailable = showVerifyFiles && isInstalled val workshopAvailable = showWorkshop && isInstalled - val launchOptionsAvailable = showLaunchOptions && isInstalled - val betaBranchesAvailable = showBetaBranches && isInstalled + val launchOptionsAvailable = onLaunchOptions != null && isInstalled + val betaBranchesAvailable = onBetaBranches != null && isInstalled val sourceMenuEnabled = updateCheckAvailable || verifyFilesAvailable || workshopAvailable || launchOptionsAvailable || betaBranchesAvailable @@ -303,8 +305,6 @@ internal fun StoreGameDetailScreen( showCheckForUpdate = updateCheckAvailable, showVerifyFiles = verifyFilesAvailable, showWorkshop = workshopAvailable, - showLaunchOptions = launchOptionsAvailable, - showBetaBranches = betaBranchesAvailable, isCheckingForUpdate = isCheckingForUpdate, areSteamActionsEnabled = areSteamActionsEnabled, isUpdateCheckEnabled = @@ -315,8 +315,8 @@ internal fun StoreGameDetailScreen( onVerifyFiles = onVerifyFiles, onCheckForUpdate = onCheckForUpdate, onWorkshop = onWorkshop, - onLaunchOptions = onLaunchOptions, - onBetaBranches = onBetaBranches, + onLaunchOptions = if (launchOptionsAvailable) onLaunchOptions else null, + onBetaBranches = if (betaBranchesAvailable) onBetaBranches else null, ) } @@ -794,16 +794,15 @@ private fun StoreSourceTag( showCheckForUpdate: Boolean = false, showVerifyFiles: Boolean = false, showWorkshop: Boolean = false, - showLaunchOptions: Boolean = false, - showBetaBranches: Boolean = false, isCheckingForUpdate: Boolean = false, areSteamActionsEnabled: Boolean = true, isUpdateCheckEnabled: Boolean = true, onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, - onLaunchOptions: () -> Unit = {}, - onBetaBranches: () -> Unit = {}, + // null hides the menu item (see StoreGameDetailScreen's signature note). + onLaunchOptions: (() -> Unit)? = null, + onBetaBranches: (() -> Unit)? = null, ) { var menuOpen by remember { mutableStateOf(false) } var anchorHeightPx by remember { mutableIntStateOf(0) } @@ -879,14 +878,14 @@ private fun StoreSourceTag( enabled = areSteamActionsEnabled, ) { menuOpen = false; onWorkshop() } } - if (showLaunchOptions) { + if (onLaunchOptions != null) { StoreSourceMenuItem( icon = Icons.Outlined.RocketLaunch, label = stringResource(R.string.store_game_launch_options), enabled = areSteamActionsEnabled, ) { menuOpen = false; onLaunchOptions() } } - if (showBetaBranches) { + if (onBetaBranches != null) { StoreSourceMenuItem( icon = Icons.Outlined.AltRoute, label = stringResource(R.string.store_game_beta_branch), diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index a7750f19f..2a34fc429 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -5356,10 +5356,18 @@ class UnifiedActivity : (!isEpic || epicGame?.isInstalled == true) && (!isGog || gogGame?.isInstalled == true), showWorkshop = !isEpic && !isGog, - showLaunchOptions = launchOptions.size >= 2, - onLaunchOptions = { showLaunchOptionsDialog = true }, - showBetaBranches = betaBranches.size >= 2, - onBetaBranches = { showBetaBranchesDialog = true }, + onLaunchOptions = + if (launchOptions.size >= 2) { + { showLaunchOptionsDialog = true } + } else { + null + }, + onBetaBranches = + if (betaBranches.size >= 2) { + { showBetaBranchesDialog = true } + } else { + null + }, areSteamActionsEnabled = when { isEpic -> !hasBlockingEpicDownloadForLibrary @@ -9257,10 +9265,18 @@ class UnifiedActivity : showWorkshop = isReallyInstalled, showVerifyFiles = isReallyInstalled, areSteamActionsEnabled = !hasBlockingSteamDownload, - showLaunchOptions = launchOptions.size >= 2, - onLaunchOptions = { showLaunchOptionsDialog = true }, - showBetaBranches = betaBranches.size >= 2, - onBetaBranches = { showBetaBranchesDialog = true }, + 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, diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 69d0aa473..756e41240 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -92,11 +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.io.FileUtils -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 From b23ec0e61d0f6e9007ed0de19151d326ac3bdbcb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 22:10:35 +0000 Subject: [PATCH 12/25] fix(steam): split StoreGameDetailScreen prologue from body for ART verifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second VerifyError from the same device, different instruction: [0x1749] copy-cat1 v0<-v260 type=Reference: WindowInsets v260 is the tell: with the 40+-param defaulted signature and the whole screen body compiled into one method, the frame needed 260+ registers, and at that pressure D8's prologue emits register copies this device's ART verifier rejects. Reshaping parameters (previous commit) just moved the failure — the giant shared frame is the trigger. Fix splits the two pressure sources into separate methods: • StoreGameDetailScreen keeps the exact public defaulted signature but its body is now only "pack args into StoreGameDetailParams, call content" — a small frame containing the default-mask prologue. • StoreGameDetailContent(p) holds the original body behind local rebinds (body text unchanged) — a big body but a one-param method with no default machinery, so the frame stays well under the high-register regime. Call sites are untouched; behavior is identical. https://claude.ai/code/session_01EJFSZYUPkR1Cwer6CC2cNa --- .../main/app/shell/StoreGameDetailScreen.kt | 152 +++++++++++++++++- 1 file changed, 148 insertions(+), 4 deletions(-) diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt index 4dacae1ad..89bbe081d 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -123,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, @@ -151,10 +204,7 @@ internal fun StoreGameDetailScreen( showWorkshop: Boolean = false, showVerifyFiles: Boolean = false, areSteamActionsEnabled: Boolean = true, - // Nullable on purpose: null hides the menu item. Folding show+callback into - // one parameter keeps this signature at the size that still passes ART's - // bytecode verifier — at ~44 params D8 emitted a default-handler this - // device's verifier rejected (VerifyError: wide register Low-half). + // Nullable on purpose: null hides the menu item. onLaunchOptions: (() -> Unit)? = null, onBetaBranches: (() -> Unit)? = null, dlcs: List = emptyList(), @@ -172,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) } From 20d32af518abd2ab96fe087a9da937b45ff954fb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 02:57:19 +0000 Subject: [PATCH 13/25] review(steam): final cleanup pass over launch options + beta branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Debloat from the crash investigation, now that the real cause (VerifyError, fixed by the wrapper/content split) is known: • Reverted the speculative runCatching guards added while hunting blind — GameManagerDialog's install-data load, the DLC size effect and the onInstall download call are back to the unguarded house style they had on main. Kept the two intentional guards: the launch-options/beta loader effects (network-dependent PICS refresh; failure should hide menu items, not kill the dialog) and startUpdateCheck's downloadAppForUpdate (our feature's primary flow). • Extracted refreshAppInfoFromPicsOnce — the once-per-process PICS refresh guard previously duplicated inside both loader functions. • Trimmed stale verifier-saga comments from the nullable menu-callback params; the full story lives once, on StoreGameDetailParams where the split it explains actually is. https://claude.ai/code/session_01EJFSZYUPkR1Cwer6CC2cNa --- .../main/app/shell/LibraryGameLaunchScreen.kt | 5 +- .../main/app/shell/StoreGameDetailScreen.kt | 2 +- app/src/main/app/shell/UnifiedActivity.kt | 106 ++++++++---------- 3 files changed, 48 insertions(+), 65 deletions(-) diff --git a/app/src/main/app/shell/LibraryGameLaunchScreen.kt b/app/src/main/app/shell/LibraryGameLaunchScreen.kt index 254abec01..a8ccf426b 100644 --- a/app/src/main/app/shell/LibraryGameLaunchScreen.kt +++ b/app/src/main/app/shell/LibraryGameLaunchScreen.kt @@ -135,8 +135,7 @@ internal fun LibraryGameLaunchScreen( showVerifyFiles: Boolean = true, showCheckForUpdate: Boolean = true, showWorkshop: Boolean = true, - // Nullable on purpose: null hides the menu item (mirrors StoreGameDetailScreen, - // whose signature size tripped ART's bytecode verifier with separate flags). + // Nullable on purpose: null hides the menu item. onLaunchOptions: (() -> Unit)? = null, onBetaBranches: (() -> Unit)? = null, playEnabled: Boolean = true, @@ -815,7 +814,7 @@ private fun SourceTag( onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, - // null hides the menu item (see LibraryGameLaunchScreen's signature note). + // Nullable on purpose: null hides the menu item. onLaunchOptions: (() -> Unit)? = null, onBetaBranches: (() -> Unit)? = null, ) { diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt index 89bbe081d..36031aaec 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -944,7 +944,7 @@ private fun StoreSourceTag( onVerifyFiles: () -> Unit = {}, onCheckForUpdate: () -> Unit = {}, onWorkshop: () -> Unit = {}, - // null hides the menu item (see StoreGameDetailScreen's signature note). + // Nullable on purpose: null hides the menu item. onLaunchOptions: (() -> Unit)? = null, onBetaBranches: (() -> Unit)? = null, ) { diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 2a34fc429..74e89bdac 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -8861,8 +8861,8 @@ class UnifiedActivity : } // Apps whose appinfo was already re-fetched this process (see - // loadSteamLaunchOptionsRefreshing / loadSteamBetaBranchesRefreshing) — - // avoids a PICS round-trip on every game-detail open. + // refreshAppInfoFromPicsOnce) — avoids a PICS round-trip on every + // game-detail open. private val appinfoRefreshedApps = java.util.Collections.synchronizedSet(mutableSetOf()) /** @@ -8899,6 +8899,19 @@ class UnifiedActivity : 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 @@ -8910,14 +8923,9 @@ class UnifiedActivity : ) { val (options, selected) = loadSteamLaunchOptions(appId) apply(options, selected) - if (appinfoRefreshedApps.add(appId)) { - if (SteamService.refreshAppInfoFromPics(appId)) { - val (fresh, freshSelected) = loadSteamLaunchOptions(appId) - apply(fresh, freshSelected) - } else { - // Offline or fetch failed — allow a retry on the next open. - appinfoRefreshedApps.remove(appId) - } + if (refreshAppInfoFromPicsOnce(appId)) { + val (fresh, freshSelected) = loadSteamLaunchOptions(appId) + apply(fresh, freshSelected) } } @@ -8966,19 +8974,20 @@ class UnifiedActivity : 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 (appinfoRefreshedApps.add(appId)) { - if (SteamService.refreshAppInfoFromPics(appId)) { - val (fresh, freshSelected) = loadSteamBetaBranches(appId) - apply(fresh, freshSelected) - } else { - appinfoRefreshedApps.remove(appId) - } + if (refreshAppInfoFromPicsOnce(appId)) { + val (fresh, freshSelected) = loadSteamBetaBranches(appId) + apply(fresh, freshSelected) } } @@ -9064,39 +9073,25 @@ class UnifiedActivity : ) LaunchedEffect(app.id, downloadRecords) { - // An exception in this effect tears down the whole Activity — degrade - // to an empty load (logged) instead of crashing the store screen. val loadData = - runCatching { - withContext(Dispatchers.IO) { - // Size the same branch the download will actually fetch. - val branch = 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, branch = branch) - } - val installedDlcIds = - SteamService.getInstalledDlcDepotsOf(app.id) - .orEmpty() - .toSet() - SteamInstallLoadData( - dlcApps = selectableDlcApps, - dlcSizes = perDlcSizes, - installedDlcIds = installedDlcIds, - baseManifestSizes = SteamService.getInstallableSelectedManifestSizes(app.id, branch = branch), - installed = SteamService.isAppInstalled(app.id), - ) - } - }.getOrElse { e -> - if (e is kotlinx.coroutines.CancellationException) throw e - Log.e("UnifiedActivity", "Steam install data load failed for appId=${app.id}", e) + withContext(Dispatchers.IO) { + // Size the same branch the download will actually fetch. + val branch = 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, branch = branch) + } + val installedDlcIds = + SteamService.getInstalledDlcDepotsOf(app.id) + .orEmpty() + .toSet() SteamInstallLoadData( - dlcApps = emptyList(), - dlcSizes = emptyMap(), - installedDlcIds = emptySet(), - baseManifestSizes = SteamService.ManifestSizes(), - installed = runCatching { withContext(Dispatchers.IO) { SteamService.isAppInstalled(app.id) } }.getOrDefault(false), + dlcApps = selectableDlcApps, + dlcSizes = perDlcSizes, + installedDlcIds = installedDlcIds, + baseManifestSizes = SteamService.getInstallableSelectedManifestSizes(app.id, branch = branch), + installed = SteamService.isAppInstalled(app.id), ) } dlcApps = loadData.dlcApps @@ -9109,16 +9104,11 @@ class UnifiedActivity : } LaunchedEffect(app.id, selectedDlcIds.toList()) { - runCatching { + selectedManifestSizes = withContext(Dispatchers.IO) { val branch = SteamService.resolveSelectedBetaName(app.id).ifBlank { "public" } SteamService.getInstallableSelectedManifestSizes(app.id, selectedDlcIds.toList(), branch = branch) } - }.onSuccess { selectedManifestSizes = it } - .onFailure { e -> - if (e is kotlinx.coroutines.CancellationException) throw e - Log.e("UnifiedActivity", "Steam DLC size load failed for appId=${app.id}", e) - } } LaunchedEffect(app.id, installed) { @@ -9295,13 +9285,7 @@ class UnifiedActivity : val installableDlcIds = dlcItems .filter { !it.isInstalled && it.id in selectedDlcIds } .map { it.id } - // An exception here is an app crash (plain launch, no - // handler) — surface it as a failed start instead. - runCatching { - SteamService.downloadApp(app.id, installableDlcIds, false, customPath) - }.onFailure { e -> - Log.w("UnifiedActivity", "Steam download failed to start for appId=${app.id}", e) - } + SteamService.downloadApp(app.id, installableDlcIds, false, customPath) withContext(Dispatchers.Main) { onDismissRequest() } } } From 23da5a80f9743f12bbbe90dc12937a293d61651c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 13:29:37 +0000 Subject: [PATCH 14/25] =?UTF-8?q?feat(steam):=20pick=20beta=20branch=20bef?= =?UTF-8?q?ore=20downloading=20=E2=80=94=20split=20Download=20button?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-install, the store screen's Download pill now has a small segment "sliced off" its left edge (AltRoute icon, 48dp — min touch target, mirrored 14dp/4dp corners with the house 8dp gap) that opens the existing beta-branch picker. The pick is STAGED in memory only (pendingBetaBranch); size cards retarget to the staged branch immediately, and nothing persists or downloads until the Download button commits it. • StoreCtaButton gains defaulted modifier/shape params; the glass brushes are extracted into storeCtaGlassBrushes shared with the new StoreCtaCutoutButton. Zero new StoreGameDetailScreen params — the existing nullable onBetaBranches drives the cutout pre-install and the STEAM-menu item post-install, so Epic/GOG (null) never show it. • GameManagerDialog loads beta branches for both install states (after the install state resolves, so the cutout can't flash on installed games); launch options stay install-gated. • BetaBranchesDialog is now a dumb shell with a single onSelect — the installed hosts (GameManager + library) persist and start the update check exactly as before; the pre-install host just stages and closes. • Download commit: persists a custom install path BEFORE the branch write (setSelectedBetaBranch creates the shortcut, which snapshots game_install_path and is never rewritten — committing in the other order would freeze the default path and break launch from custom dirs), then downloads; pause/resume re-resolves the branch from the now-persisted shortcut. If the shortcut can't be created yet (setup incomplete) the install proceeds on public with the existing beta-branch-failed toast. https://claude.ai/code/session_01EJFSZYUPkR1Cwer6CC2cNa --- .../main/app/shell/StoreGameDetailScreen.kt | 230 +++++++++++++----- app/src/main/app/shell/UnifiedActivity.kt | 126 +++++++--- 2 files changed, 260 insertions(+), 96 deletions(-) diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt index 36031aaec..ddee3d272 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -658,14 +658,55 @@ private fun StoreGameDetailContent(p: StoreGameDetailParams) { } 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. + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(actionIconSpacing), + ) { + StoreCtaCutoutButton( + height = ctaHeight, + icon = Icons.Outlined.AltRoute, + contentDescription = stringResource(R.string.store_game_beta_branch), + enabled = !isLoading, + shape = + RoundedCornerShape( + topStart = 14.dp, + bottomStart = 14.dp, + topEnd = 4.dp, + bottomEnd = 4.dp, + ), + onClick = onBetaBranches, + modifier = Modifier.width(actionIconSize), + ) + 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, + ), + ) + } + } else { + StoreCtaButton( + height = ctaHeight, + icon = Icons.Outlined.Download, + label = stringResource(R.string.common_ui_download), + enabled = !isLoading && isDownloadActionEnabled, + loading = isLoading, + onClick = onInstall, + ) + } } } } @@ -1193,6 +1234,67 @@ 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, +): 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), + ), + ) + } else { + Brush.horizontalGradient( + colors = + listOf( + Color(0xFF3A3A4A).copy(alpha = 0.35f), + Color(0xFF2A2A36).copy(alpha = 0.35f), + ), + ) + }, + 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, @@ -1201,6 +1303,8 @@ private fun StoreCtaButton( enabled: Boolean, loading: Boolean, onClick: () -> Unit, + modifier: Modifier = Modifier.fillMaxWidth(), + shape: RoundedCornerShape = RoundedCornerShape(14.dp), ) { val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() @@ -1214,64 +1318,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) 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, @@ -1309,6 +1367,60 @@ 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, +) { + 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) + 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 74e89bdac..9605bffd7 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -5862,13 +5862,24 @@ class UnifiedActivity : if (showBetaBranchesDialog) { BetaBranchesDialog( - appId = app.id, gameTitle = app.name, branches = betaBranches, selectedBranch = selectedBetaBranch, - onSelectionSaved = { selectedBetaBranch = it }, + 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 }, - onStartUpdate = { startUpdateCheck(app.id, app.name) }, ) } } @@ -9043,6 +9054,8 @@ class UnifiedActivity : 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( @@ -9072,11 +9085,14 @@ 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. - val branch = SteamService.resolveSelectedBetaName(app.id).ifBlank { "public" } + // 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 -> @@ -9103,28 +9119,34 @@ class UnifiedActivity : isLoading = false } - LaunchedEffect(app.id, selectedDlcIds.toList()) { + LaunchedEffect(app.id, selectedDlcIds.toList(), pendingBetaBranch) { selectedManifestSizes = withContext(Dispatchers.IO) { - val branch = SteamService.resolveSelectedBetaName(app.id).ifBlank { "public" } + 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) { 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 + 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 @@ -9285,6 +9307,33 @@ class UnifiedActivity : val installableDlcIds = dlcItems .filter { !it.isInstalled && it.id in selectedDlcIds } .map { it.id } + // Commit the staged branch pick before the download so + // downloadApp (and any later resume) resolves it from the + // shortcut. Custom path first: the shortcut snapshots + // game_install_path at creation and is never rewritten. + val pendingBranch = pendingBetaBranch + if (installed != true && pendingBranch != null) { + customPath?.let { SteamService.setCustomInstallPath(app.id, it) } + val branchName = + if (pendingBranch.name.equals("public", ignoreCase = true)) { + "" + } else { + pendingBranch.name + } + val saved = + SteamService.setSelectedBetaBranch(applicationContext, app.id, branchName) + if (!saved) { + // Setup incomplete (no usable container yet) — install + // proceeds on the public branch rather than blocking. + withContext(Dispatchers.Main) { + com.winlator.cmod.shared.ui.toast.WinToast.show( + context, + getString(R.string.store_game_beta_branch_failed), + android.widget.Toast.LENGTH_SHORT, + ) + } + } + } SteamService.downloadApp(app.id, installableDlcIds, false, customPath) withContext(Dispatchers.Main) { onDismissRequest() } } @@ -9419,13 +9468,30 @@ class UnifiedActivity : if (showBetaBranchesDialog) { BetaBranchesDialog( - appId = app.id, gameTitle = app.name, branches = betaBranches, - selectedBranch = selectedBetaBranch, - onSelectionSaved = { selectedBetaBranch = it }, + selectedBranch = if (installed == true) selectedBetaBranch else 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 }, - onStartUpdate = { startUpdateCheck(app.id, app.name) }, ) } } @@ -9467,19 +9533,18 @@ class UnifiedActivity : /** * Hosts the Workshop-styled beta-branch picker window over a game detail - * dialog. Selecting an unlocked row persists it and triggers [onStartUpdate]. + * 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( - appId: Int, gameTitle: String, branches: List, selectedBranch: StoreBetaBranchItem?, - onSelectionSaved: (StoreBetaBranchItem) -> Unit, + onSelect: (StoreBetaBranchItem) -> Unit, onDismissRequest: () -> Unit, - onStartUpdate: () -> Unit, ) { - val scope = rememberCoroutineScope() Dialog( onDismissRequest = onDismissRequest, properties = @@ -9492,20 +9557,7 @@ class UnifiedActivity : gameTitle = gameTitle, branches = branches, selectedBranch = selectedBranch, - onSelect = { item -> - persistSteamBetaBranchSelection( - appId = appId, - item = item, - scope = scope, - onSaved = { saved -> - onSelectionSaved(saved) - // Close the picker so the update-check flow it triggers - // is what the user sees next, not a stale window. - onDismissRequest() - }, - startUpdate = onStartUpdate, - ) - }, + onSelect = onSelect, onClose = onDismissRequest, ) } From e47edb547f3d607d550f2e0fd3fdc96a1fbda016 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 18:09:58 +0000 Subject: [PATCH 15/25] fix(steam): recover beta-branch selection lost to an app reinstall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reinstalling WinNative wipes app-private shortcuts (where the selectedBranch extra lives) while game files on external storage survive — so a reinstalled app showed a beta install as "public", and worse, a tapped update check would diff the beta files against public manifests and silently downgrade the game. Steam survives this because it persists the beta key WITH the install (appmanifest ACF, UserConfig/betakey). Our durable analogs live in the surviving game dir, used in order of trust by the new recoverSelectedBetaName(appId): 1. .DepotDownloader/depot.config — the installed manifest gids matched exactly against each beta's PICS manifests (evidence of what the files actually are; requires all overlapping depots to match and at least one gid to differ from public, single candidate only); 2. steam_settings/configs.app.ini branch_name — the selection recorded at the game's last launch (checked at the game-dir root and next to the exe, the two settings-dir locations the launch path writes). A successful inference heals the shortcut via setSelectedBetaBranch, so every downstream consumer is consistent again. Wired at the three chokepoints: the branch-picker loader (UI), checkForAppUpdate / isUpdatePending (branch param now nullable, null = recover-and-resolve) and prepareLibSteamClientForLaunch (game launch). Known limitation: a never-launched install whose beta has since published a new build matches neither source and stays public until re-picked. https://claude.ai/code/session_01EJFSZYUPkR1Cwer6CC2cNa --- app/src/main/app/shell/UnifiedActivity.kt | 4 +- .../stores/steam/service/SteamService.kt | 123 +++++++++++++++++- 2 files changed, 123 insertions(+), 4 deletions(-) diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 9605bffd7..c1cc11e18 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -8977,7 +8977,9 @@ class UnifiedActivity : ?.map { b -> StoreBetaBranchItem(b.name, b.buildId, b.timeUpdated, b.pwdRequired) } ?.sortedWith(compareBy({ !it.name.equals("public", ignoreCase = true) }, { it.name })) .orEmpty() - val selectedName = SteamService.resolveSelectedBetaName(appId).ifBlank { "public" } + // 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. diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 756e41240..3887e6854 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -5497,7 +5497,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 -> @@ -5590,6 +5590,118 @@ class SteamService : Service() { }.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. + */ + suspend fun recoverSelectedBetaName(appId: Int): String { + val persisted = resolveSelectedBetaName(appId) + if (persisted.isNotEmpty()) return persisted + 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) + ?.takeIf { name -> betaNames.any { it.equals(name, ignoreCase = true) } } + if (inferred.isNullOrEmpty()) return@runCatching "" + if (setSelectedBetaBranch(svc, appId, inferred)) { + 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 = + getWindowsLaunchInfos(app.id) + .firstOrNull() + ?.executable + ?.replace('\\', '/') + ?.substringBeforeLast('/', "") + .orEmpty() + val candidates = + buildList { + add(File(appDirPath, "steam_settings/configs.app.ini")) + if (exeDir.isNotEmpty()) { + add(File(appDirPath, "$exeDir/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 @@ -7448,14 +7560,19 @@ class SteamService : Service() { suspend fun isUpdatePending( appId: Int, - branch: String = resolveSelectedBetaName(appId).ifBlank { "public" }, + branch: String? = null, ): Boolean = checkForAppUpdate(appId, branch).hasUpdate suspend fun checkForAppUpdate( appId: Int, - branch: String = resolveSelectedBetaName(appId).ifBlank { "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 " + From dae3f27038278f2dbf667779ae4c90dc8e673d8f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 11 Jun 2026 18:40:25 +0000 Subject: [PATCH 16/25] review(steam): multi-angle review fixes for pre-download selector + recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From a seven-angle review of the two newest commits: • Branch-name casing: an ini-recovered name is now canonicalized to the PICS branches-map key before healing — downstream buildId lookups (app.branches[name]) are exact-key and would silently miss an ini-cased variant. • Recovery is memoized per process (betaRecoveryAttemptedApps): it can only succeed right after a WinNative reinstall, so permanently-public games no longer rerun the full inference (DB read + depot.config parse + ini probes) on every dialog open, update check and launch. • readBranchNameFromSettingsIni derives the exe dir from the SteamApp already in hand instead of getWindowsLaunchInfos' redundant blocking DB fetch of the same row. • pendingBetaBranch is cleared when a game resolves to installed — a staged pre-install pick was either committed by Download or abandoned, and could otherwise leak into a later session of the dialog. The host's selectedBranch expression simplifies to pending ?: persisted on that invariant. • The branch cutout now shares the Download button's enabled gate so users can't stage picks an actively-blocked download won't honor. • The 20-line commit block in onInstall is extracted to commitPendingBetaBranch, mirroring the persist helper next to it. Refuted during verification, for the record: cutout-without-betas (the host gates onBetaBranches on branches.size >= 2), cross-game state leaks (remember(app.id) keying), setSelectedBetaBranch false-success (it returns false on a null shortcut), customPath double-application (idempotent by construction), and the lost explicit-public API (callers can pass requestedBranch = "public"). https://claude.ai/code/session_01EJFSZYUPkR1Cwer6CC2cNa --- .../main/app/shell/StoreGameDetailScreen.kt | 4 +- app/src/main/app/shell/UnifiedActivity.kt | 62 ++++++++++--------- .../stores/steam/service/SteamService.kt | 26 +++++--- 3 files changed, 54 insertions(+), 38 deletions(-) diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt index ddee3d272..6d1d6167b 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -669,7 +669,9 @@ private fun StoreGameDetailContent(p: StoreGameDetailParams) { height = ctaHeight, icon = Icons.Outlined.AltRoute, contentDescription = stringResource(R.string.store_game_beta_branch), - enabled = !isLoading, + // Same gate as Download: don't stage picks the + // blocked download can't honor. + enabled = !isLoading && isDownloadActionEnabled, shape = RoundedCornerShape( topStart = 14.dp, diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index c1cc11e18..980023482 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -9004,6 +9004,31 @@ class UnifiedActivity : } } + /** + * 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, @@ -9135,7 +9160,11 @@ class UnifiedActivity : // 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) { + 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 } @@ -9309,32 +9338,8 @@ class UnifiedActivity : val installableDlcIds = dlcItems .filter { !it.isInstalled && it.id in selectedDlcIds } .map { it.id } - // Commit the staged branch pick before the download so - // downloadApp (and any later resume) resolves it from the - // shortcut. Custom path first: the shortcut snapshots - // game_install_path at creation and is never rewritten. - val pendingBranch = pendingBetaBranch - if (installed != true && pendingBranch != null) { - customPath?.let { SteamService.setCustomInstallPath(app.id, it) } - val branchName = - if (pendingBranch.name.equals("public", ignoreCase = true)) { - "" - } else { - pendingBranch.name - } - val saved = - SteamService.setSelectedBetaBranch(applicationContext, app.id, branchName) - if (!saved) { - // Setup incomplete (no usable container yet) — install - // proceeds on the public branch rather than blocking. - withContext(Dispatchers.Main) { - com.winlator.cmod.shared.ui.toast.WinToast.show( - context, - getString(R.string.store_game_beta_branch_failed), - android.widget.Toast.LENGTH_SHORT, - ) - } - } + if (installed != true) { + pendingBetaBranch?.let { commitPendingBetaBranch(app.id, it, customPath) } } SteamService.downloadApp(app.id, installableDlcIds, false, customPath) withContext(Dispatchers.Main) { onDismissRequest() } @@ -9472,7 +9477,8 @@ class UnifiedActivity : BetaBranchesDialog( gameTitle = app.name, branches = betaBranches, - selectedBranch = if (installed == true) selectedBetaBranch else pendingBetaBranch ?: selectedBetaBranch, + // pendingBetaBranch is null for installed games (cleared on install). + selectedBranch = pendingBetaBranch ?: selectedBetaBranch, onSelect = { item -> if (installed == true) { persistSteamBetaBranchSelection( diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 3887e6854..b850d3d71 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -5606,9 +5606,17 @@ class SteamService : Service() { * 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 "" @@ -5620,7 +5628,9 @@ class SteamService : Service() { val inferred = inferBranchFromInstalledManifests(app, betaNames, appDirPath) ?: readBranchNameFromSettingsIni(app, appDirPath) - ?.takeIf { name -> betaNames.any { it.equals(name, ignoreCase = true) } } + // 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)) { Timber.i("Recovered beta branch '$inferred' for appId=$appId from the installed game dir") @@ -5676,19 +5686,17 @@ class SteamService : Service() { appDirPath: String, ): String? { val exeDir = - getWindowsLaunchInfos(app.id) - .firstOrNull() + app.config.launch + .firstOrNull { it.executable.endsWith(".exe") } ?.executable ?.replace('\\', '/') ?.substringBeforeLast('/', "") .orEmpty() val candidates = - buildList { - add(File(appDirPath, "steam_settings/configs.app.ini")) - if (exeDir.isNotEmpty()) { - add(File(appDirPath, "$exeDir/steam_settings/configs.app.ini")) - } - } + 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 = From 75b64d63ad1111d9c91e1d0d277e8ea7e4592a27 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 00:03:44 +0000 Subject: [PATCH 17/25] ui(steam): one continuous gradient across the split Download pill The beta-branch cutout and the Download segment each painted the full teal->purple gradient, so it repeated. Each segment now paints only its slice of a single gradient spanning the whole row, matching the look of the undivided Download button. https://claude.ai/code/session_013E3pTpGg4C6ropUPA9QyNB --- .../main/app/shell/StoreGameDetailScreen.kt | 31 +++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/app/src/main/app/shell/StoreGameDetailScreen.kt b/app/src/main/app/shell/StoreGameDetailScreen.kt index 6d1d6167b..8836970da 100644 --- a/app/src/main/app/shell/StoreGameDetailScreen.kt +++ b/app/src/main/app/shell/StoreGameDetailScreen.kt @@ -661,8 +661,19 @@ private fun StoreGameDetailContent(p: StoreGameDetailParams) { 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(), + modifier = + Modifier + .fillMaxWidth() + .onSizeChanged { splitCtaWidthPx = it.width }, horizontalArrangement = Arrangement.spacedBy(actionIconSpacing), ) { StoreCtaCutoutButton( @@ -681,6 +692,9 @@ private fun StoreGameDetailContent(p: StoreGameDetailParams) { ), onClick = onBetaBranches, modifier = Modifier.width(actionIconSize), + fillEndX = + splitCtaWidthPx.takeIf { it > 0 }?.toFloat() + ?: Float.POSITIVE_INFINITY, ) StoreCtaButton( height = ctaHeight, @@ -697,6 +711,7 @@ private fun StoreGameDetailContent(p: StoreGameDetailParams) { topEnd = 14.dp, bottomEnd = 14.dp, ), + fillStartX = -splitCtaLeadPx, ) } } else { @@ -1246,6 +1261,8 @@ private class StoreCtaGlass( private fun storeCtaGlassBrushes( enabled: Boolean, flare: Float, + fillStartX: Float = 0f, + fillEndX: Float = Float.POSITIVE_INFINITY, ): StoreCtaGlass = StoreCtaGlass( fill = @@ -1257,6 +1274,8 @@ private fun storeCtaGlassBrushes( StoreAccent.copy(alpha = 0.38f), Color(0xFF7B2FF7).copy(alpha = 0.38f), ), + startX = fillStartX, + endX = fillEndX, ) } else { Brush.horizontalGradient( @@ -1265,6 +1284,8 @@ private fun storeCtaGlassBrushes( Color(0xFF3A3A4A).copy(alpha = 0.35f), Color(0xFF2A2A36).copy(alpha = 0.35f), ), + startX = fillStartX, + endX = fillEndX, ) }, sheen = @@ -1307,6 +1328,8 @@ private fun StoreCtaButton( 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() @@ -1320,7 +1343,7 @@ private fun StoreCtaButton( animationSpec = spring(dampingRatio = 0.6f, stiffness = 500f), label = "storeCtaFlare", ) - val glass = storeCtaGlassBrushes(enabled, flare) + val glass = storeCtaGlassBrushes(enabled, flare, fillStartX, fillEndX) Box( modifier = modifier @@ -1382,6 +1405,8 @@ private fun StoreCtaCutoutButton( shape: RoundedCornerShape, onClick: () -> Unit, modifier: Modifier = Modifier, + fillStartX: Float = 0f, + fillEndX: Float = Float.POSITIVE_INFINITY, ) { val interactionSource = remember { MutableInteractionSource() } val isPressed by interactionSource.collectIsPressedAsState() @@ -1395,7 +1420,7 @@ private fun StoreCtaCutoutButton( animationSpec = spring(dampingRatio = 0.6f, stiffness = 500f), label = "storeCtaCutoutFlare", ) - val glass = storeCtaGlassBrushes(enabled, flare) + val glass = storeCtaGlassBrushes(enabled, flare, fillStartX, fillEndX) Box( modifier = modifier From 1473a13006a2f594539162c705f9f3f3a041c95c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 11:06:11 +0000 Subject: [PATCH 18/25] fix(steam): delete stale game files when a branch switch removes them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The depot writer only ever wrote new/changed files, so switching beta branches (or any update that drops files) left the previous build's files on disk until a fresh install — leftover binaries could break the game. New depot_cleanup.rs runs a manifest-diff pass after every fully successful download: before a depot moves off its installed manifest, a .stalecleanup marker records the old gid; once all depots commit, files listed in the old manifest but absent from the union of all currently-installed manifests are deleted (logged per file), Steamless .original.exe backups go with their primary, and emptied directories are pruned. Candidates come only from the old manifest, so user files, saves, steam_settings/ and other non-manifest content can never be touched. If the keep-union can't be built completely (missing key or unreadable cached manifest), the pass defers and the marker survives for the next successful download; pause/cancel never deletes anything. pruneStaleDepotManifestCache now keeps manifest caches that a pending marker still needs and drops orphaned markers. https://claude.ai/code/session_01RPe9jUg4EkHe7in3RzDra3 --- .../wn-steam-client/rust/src/depot_cleanup.rs | 554 ++++++++++++++++++ .../wn-steam-client/rust/src/depot_config.rs | 7 + .../rust/src/depot_downloader.rs | 67 +++ .../main/cpp/wn-steam-client/rust/src/jni.rs | 2 +- .../main/cpp/wn-steam-client/rust/src/lib.rs | 1 + .../stores/steam/service/SteamService.kt | 23 +- 6 files changed, 649 insertions(+), 5 deletions(-) create mode 100644 app/src/main/cpp/wn-steam-client/rust/src/depot_cleanup.rs 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..9f1d3e771 --- /dev/null +++ b/app/src/main/cpp/wn-steam-client/rust/src/depot_cleanup.rs @@ -0,0 +1,554 @@ +use crate::content_manifest::{ContentManifest, FileMapping}; +use crate::depot_config::{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"; + +fn cleanup_log(message: &str) { + crate::jni::android_log("WnSteamDepotCleanup", message); +} + +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)) +} + +/// 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; + } + let path = stale_cleanup_marker_path(config_dir, depot_id, old_manifest_id); + let Some(parent) = path.parent() else { + return false; + }; + if fs::create_dir_all(parent).is_err() { + return false; + } + fs::write(path, old_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 the OLD manifest of +/// a depot with a pending marker, minus the union of every currently-installed +/// manifest of the app. Files WinNative itself adds (steam_settings/, +/// .DepotDownloader/, *.original.exe backups, saves) appear in no manifest and +/// can never become candidates. If the keep-union cannot be built completely +/// (missing depot key or unreadable cached manifest), the whole pass aborts +/// and markers are kept for a later attempt. +/// +/// Returns the number of files deleted. +pub fn run_stale_file_cleanup( + install_dir: &str, + config_dir: &Path, + depots: &[ResolvedDepotSpec], +) -> u32 { + let markers = pending_cleanup_markers(config_dir); + if markers.is_empty() { + return 0; + } + + let keys: BTreeMap> = depots + .iter() + .map(|depot| (depot.depot_id, &depot.depot_key)) + .collect(); + let cfg = DepotConfigStore::load(config_dir); + + let mut keep = BTreeSet::new(); + for (depot_id, manifest_id) in cfg.installed_entries() { + if manifest_id == INVALID_MANIFEST_ID { + cleanup_log(&format!( + "cleanup: depot {depot_id} still in progress, deferring stale-file pass" + )); + return 0; + } + let Some(key) = keys.get(&depot_id) else { + cleanup_log(&format!( + "cleanup: no key for installed depot {depot_id}, deferring stale-file pass" + )); + return 0; + }; + let Some(files) = load_manifest_files(config_dir, depot_id, manifest_id, key) else { + cleanup_log(&format!( + "cleanup: cannot read manifest {depot_id}_{manifest_id}, deferring stale-file pass" + )); + return 0; + }; + for file in &files { + keep.insert(file.filename.to_ascii_lowercase()); + } + } + + let install_root = Path::new(install_dir); + let mut deleted = 0u32; + for (depot_id, old_gid) in markers { + if cfg.installed_manifest(depot_id) == old_gid { + // Marker for the build that is (again) current — nothing stale. + remove_cleanup_marker(config_dir, depot_id, old_gid); + continue; + } + let Some(key) = keys.get(&depot_id) else { + cleanup_log(&format!( + "cleanup: no key for depot {depot_id}, dropping marker {depot_id}_{old_gid}" + )); + remove_cleanup_marker(config_dir, depot_id, old_gid); + continue; + }; + let Some(files) = load_manifest_files(config_dir, depot_id, old_gid, key) else { + cleanup_log(&format!( + "cleanup: old manifest {depot_id}_{old_gid} unavailable, dropping marker" + )); + remove_cleanup_marker(config_dir, depot_id, old_gid); + continue; + }; + + let mut dirs = BTreeSet::new(); + for file in &files { + if keep.contains(&file.filename.to_ascii_lowercase()) { + continue; + } + let Some(rel) = sanitized_relative(&file.filename) else { + cleanup_log(&format!( + "cleanup: refusing unsafe manifest path '{}'", + file.filename + )); + continue; + }; + let path = install_root.join(rel); + if (file.flags & DEPOT_FILE_FLAG_DIRECTORY) != 0 { + dirs.insert(path); + continue; + } + if delete_stale_file(&path) { + cleanup_log(&format!( + "cleanup: deleted '{}' (depot {depot_id}, gone after manifest {old_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. + 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); + } + remove_cleanup_marker(config_dir, depot_id, old_gid); + } + if deleted > 0 { + cleanup_log(&format!( + "cleanup: removed {deleted} stale file(s) under '{install_dir}'" + )); + } + deleted +} + +fn load_manifest_files( + 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.files) +} + +/// Manifest paths are normalized to '/' separators by decrypt_filenames; only +/// plain relative paths that stay inside the install dir are accepted. +fn sanitized_relative(rel: &str) -> Option<&str> { + if rel.is_empty() || rel.starts_with('/') || rel.contains(':') { + return None; + } + let mut components = rel.split('/'); + if components + .clone() + .any(|part| part.is_empty() || part == "." || part == "..") + { + return None; + } + if components + .next() + .is_some_and(|first| first.eq_ignore_ascii_case(".DepotDownloader")) + { + return None; + } + Some(rel) +} + +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) +} + +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 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 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 keeps_files_that_moved_to_another_depot() { + let install = temp_dir("multi_depot"); + let config = config_dir(&install); + write_manifest(&config, 100, 444, &[("shared.dat", 0), ("only_old.dat", 0)]); + write_manifest(&config, 100, 555, &[("core.dat", 0)]); + write_manifest(&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), spec(200, 777)], + ); + + assert_eq!(deleted, 1); + assert!(install.join("shared.dat").exists(), "moved depots keep file"); + assert!(!install.join("only_old.dat").exists()); + assert!(install.join("core.dat").exists()); + let _ = fs::remove_dir_all(&install); + } + + #[test] + fn missing_old_manifest_drops_marker_without_deleting() { + let install = temp_dir("missing_old"); + let config = config_dir(&install); + write_manifest(&config, 100, 555, &[("core.dat", 0)]); + install_current(&config, 100, 555); + touch(&install, "core.dat"); + touch(&install, "mystery.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("mystery.dat").exists()); + assert!(pending_cleanup_markers(&config).is_empty()); + let _ = fs::remove_dir_all(&install); + } + + #[test] + fn incomplete_keep_union_defers_and_keeps_marker() { + let install = temp_dir("incomplete_union"); + let config = config_dir(&install); + write_manifest(&config, 100, 444, &[("only_old.dat", 0)]); + write_manifest(&config, 100, 555, &[("core.dat", 0)]); + install_current(&config, 100, 555); + install_current(&config, 200, 777); // installed depot with no cached manifest + touch(&install, "only_old.dat"); + assert!(record_pending_cleanup(&config, 100, 444, 555)); + + // Depot 200's manifest cache is missing → whole pass defers. + let deleted = run_stale_file_cleanup( + install.to_str().unwrap(), + &config, + &[spec(100, 555), spec(200, 777)], + ); + assert_eq!(deleted, 0); + assert!(install.join("only_old.dat").exists()); + assert_eq!(pending_cleanup_markers(&config), vec![(100, 444)]); + + // Same when the key for an installed depot is absent from this op. + write_manifest(&config, 200, 777, &[("dlc.dat", 0)]); + let deleted = run_stale_file_cleanup(install.to_str().unwrap(), &config, &[spec(100, 555)]); + assert_eq!(deleted, 0); + assert_eq!(pending_cleanup_markers(&config), vec![(100, 444)]); + let _ = fs::remove_dir_all(&install); + } + + #[test] + fn rejects_unsafe_manifest_paths() { + assert_eq!(sanitized_relative("bin/game.exe"), Some("bin/game.exe")); + assert_eq!(sanitized_relative(""), None); + assert_eq!(sanitized_relative("/etc/passwd"), None); + assert_eq!(sanitized_relative("../outside.dat"), None); + assert_eq!(sanitized_relative("bin/../../outside.dat"), None); + assert_eq!(sanitized_relative("bin//double.dat"), None); + assert_eq!(sanitized_relative("c:/windows/system32"), None); + assert_eq!(sanitized_relative(".DepotDownloader/depot.config"), None); + assert_eq!(sanitized_relative(".depotdownloader/depot.config"), None); + } + + #[test] + fn deletes_steamless_backup_with_its_primary() { + let install = temp_dir("steamless_backup"); + let config = config_dir(&install); + write_manifest(&config, 100, 444, &[("old.exe", 0), ("game.exe", 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"); + 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, 2); + 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()); + 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..700c89d9b 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) 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..8b33630a3 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,6 @@ use crate::cdn_client::{CdnClient, CdnManifestResult}; use crate::content_manifest::ContentManifest; +use crate::depot_cleanup::{record_pending_cleanup, run_stale_file_cleanup}; use crate::depot_config::{DepotConfigStore, DepotProgressStore, INVALID_MANIFEST_ID}; use crate::depot_writer::{write_depot_sequential, DepotWriteOptions}; use crate::pb::ccontentserverdirectory::CContentServerDirectoryServerInfo; @@ -316,6 +317,14 @@ pub fn download_resolved_depots_with_cancel_progress( let mut cfg = DepotConfigStore::load(&config_dir); if fresh { + for depot in depots { + record_pending_cleanup( + &config_dir, + depot.depot_id, + cfg.installed_manifest(depot.depot_id), + depot.manifest_id, + ); + } cfg.discard(); for depot in depots { DepotProgressStore::remove(&config_dir, depot.depot_id, depot.manifest_id); @@ -350,6 +359,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 {}", @@ -449,6 +464,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 +682,55 @@ mod tests { let _ = fs::remove_dir_all(&dir); } + #[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/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index b850d3d71..a21738034 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -7756,23 +7756,38 @@ class SteamService : Service() { * 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. + * 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. */ private fun pruneStaleDepotManifestCache(appDirPath: String) { runCatching { val installedManifests = readInstalledDepotManifestIds(appDirPath) if (installedManifests.isEmpty()) return - File(appDirPath, ".DepotDownloader") + val depotDownloaderDir = File(appDirPath, ".DepotDownloader") + depotDownloaderDir .listFiles { f -> f.isFile && f.name.endsWith(".manifest") } ?.forEach { f -> - val parts = f.name.removeSuffix(".manifest").split('_') + val stem = f.name.removeSuffix(".manifest") + 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 && f.delete()) { + 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 whose manifest cache is gone can never be acted on. + depotDownloaderDir + .listFiles { f -> f.isFile && f.name.endsWith(".stalecleanup") } + ?.forEach { f -> + val stem = f.name.removeSuffix(".stalecleanup") + if (!File(depotDownloaderDir, "$stem.manifest").isFile && f.delete()) { + Timber.i("Dropped orphaned stale-cleanup marker ${f.name} at $appDirPath") + } + } }.onFailure { e -> Timber.w(e, "Stale manifest prune failed for $appDirPath") } } From 58d36470c3caec20ffd1ac44a5ae9bab48a008a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 13:54:21 +0000 Subject: [PATCH 19/25] review(steam): make stale-file cleanup work in narrowed updates; harden deletion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-angle review fixes for the manifest-diff deletion pass: - Real branch switches arrive as narrowed updates (only changed depots, per-app DLC batches), so the keep-union could never collect keys for every installed depot and the pass deferred forever. Each download now persists a decrypted "{depot}_{gid}.filelist" sidecar (and backfills one when skipping an already-installed depot), so the union and the old-build file lists read without depot keys. Union building is best-effort over readable lists — same model as the reference DepotDownloader — instead of all-or-nothing. - Per-batch fresh downloads (Verify Files) wiped all of depot.config, blinding the union to other batches' depots and opening a window to delete files a still-installed depot ships. fresh now discards only the operation's own depots. - A marker whose old list is on disk but unreadable in this op is deferred, not dropped; dropped only when the data is gone for good. - Steamless backup deletion is gated: exe primaries only, skipped when a current manifest ships that exact backup name, only after the primary was actually deleted. - Keep-union and candidates normalize through the same component-level canonicalization ("./a", "a//b" spellings match), and candidates behind a symlinked directory are skipped so manifest-created symlinks can't redirect deletion outside the install dir. - Kotlin manifest-cache prune handles .filelist sidecars with the same keep/prune lifecycle and drops markers only when neither manifest nor sidecar remains. https://claude.ai/code/session_01RPe9jUg4EkHe7in3RzDra3 --- .../wn-steam-client/rust/src/depot_cleanup.rs | 566 ++++++++++++++---- .../wn-steam-client/rust/src/depot_config.rs | 35 +- .../rust/src/depot_downloader.rs | 84 ++- .../stores/steam/service/SteamService.kt | 18 +- 4 files changed, 568 insertions(+), 135 deletions(-) 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 index 9f1d3e771..b756f6e03 100644 --- 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 @@ -1,5 +1,5 @@ -use crate::content_manifest::{ContentManifest, FileMapping}; -use crate::depot_config::{DepotConfigStore, INVALID_MANIFEST_ID}; +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}; @@ -7,11 +7,19 @@ 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}") } @@ -26,6 +34,91 @@ pub fn stale_cleanup_marker_path( .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 @@ -86,11 +179,20 @@ fn remove_cleanup_marker(config_dir: impl AsRef, depot_id: u32, manifest_i /// /// Safety model: deletion candidates come exclusively from the OLD manifest of /// a depot with a pending marker, minus the union of every currently-installed -/// manifest of the app. Files WinNative itself adds (steam_settings/, -/// .DepotDownloader/, *.original.exe backups, saves) appear in no manifest and -/// can never become candidates. If the keep-union cannot be built completely -/// (missing depot key or unreadable cached manifest), the whole pass aborts -/// and markers are kept for a later attempt. +/// manifest's files (read from filelist sidecars, falling back to cached +/// manifests when this operation holds the depot's key). Files WinNative +/// itself adds (steam_settings/, .DepotDownloader/, *.original.exe backups, +/// saves) appear in no manifest and can never become candidates. The union is +/// best-effort: an installed depot whose file list is unreadable (legacy +/// install predating sidecars, key not in this op) is logged and skipped — +/// the per-depot old-minus-new diff itself never depends on it, which matches +/// the reference DepotDownloader behaviour while sidecars close the gap on +/// every download going forward. +/// +/// Known model limitation: a download cancelled mid-write leaves no committed +/// gid to diff against on the next switch, so files unique to the aborted +/// build are not reclaimed (depot.config holds INVALID for it; the prior +/// marker resolves as already-current). /// /// Returns the number of files deleted. pub fn run_stale_file_cleanup( @@ -103,9 +205,9 @@ pub fn run_stale_file_cleanup( return 0; } - let keys: BTreeMap> = depots + let keys: BTreeMap = depots .iter() - .map(|depot| (depot.depot_id, &depot.depot_key)) + .map(|depot| (depot.depot_id, depot.depot_key.as_slice())) .collect(); let cfg = DepotConfigStore::load(config_dir); @@ -117,20 +219,21 @@ pub fn run_stale_file_cleanup( )); return 0; } - let Some(key) = keys.get(&depot_id) else { - cleanup_log(&format!( - "cleanup: no key for installed depot {depot_id}, deferring stale-file pass" - )); - return 0; - }; - let Some(files) = load_manifest_files(config_dir, depot_id, manifest_id, key) else { - cleanup_log(&format!( - "cleanup: cannot read manifest {depot_id}_{manifest_id}, deferring stale-file pass" - )); - return 0; - }; - for file in &files { - keep.insert(file.filename.to_ascii_lowercase()); + match load_file_entries( + config_dir, + depot_id, + manifest_id, + keys.get(&depot_id).copied(), + ) { + Some(entries) => { + for entry in &entries { + keep.insert(normalized_key(&entry.name)); + } + } + None => cleanup_log(&format!( + "cleanup: no file list for installed depot {depot_id}_{manifest_id}; \ + protecting with remaining manifests only" + )), } } @@ -142,35 +245,51 @@ pub fn run_stale_file_cleanup( remove_cleanup_marker(config_dir, depot_id, old_gid); continue; } - let Some(key) = keys.get(&depot_id) else { - cleanup_log(&format!( - "cleanup: no key for depot {depot_id}, dropping marker {depot_id}_{old_gid}" - )); - remove_cleanup_marker(config_dir, depot_id, old_gid); - continue; - }; - let Some(files) = load_manifest_files(config_dir, depot_id, old_gid, key) else { - cleanup_log(&format!( - "cleanup: old manifest {depot_id}_{old_gid} unavailable, dropping marker" - )); - remove_cleanup_marker(config_dir, depot_id, old_gid); + let Some(entries) = + load_file_entries(config_dir, depot_id, old_gid, keys.get(&depot_id).copied()) + else { + if filelist_sidecar_path(config_dir, depot_id, old_gid).is_file() + || config_dir + .join(format!("{depot_id}_{old_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}_{old_gid} unreadable in this op, deferring" + )); + } else { + cleanup_log(&format!( + "cleanup: old file list {depot_id}_{old_gid} is gone, dropping marker" + )); + remove_cleanup_marker(config_dir, depot_id, old_gid); + } continue; }; let mut dirs = BTreeSet::new(); - for file in &files { - if keep.contains(&file.filename.to_ascii_lowercase()) { + for entry in &entries { + let key = normalized_key(&entry.name); + if keep.contains(&key) { continue; } - let Some(rel) = sanitized_relative(&file.filename) else { + let Some(parts) = sanitized_components(&entry.name) else { cleanup_log(&format!( "cleanup: refusing unsafe manifest path '{}'", - file.filename + entry.name )); continue; }; - let path = install_root.join(rel); - if (file.flags & DEPOT_FILE_FLAG_DIRECTORY) != 0 { + 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; } @@ -180,18 +299,22 @@ pub fn run_stale_file_cleanup( 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. - 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()); + // 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()); + } } } } @@ -208,43 +331,85 @@ pub fn run_stale_file_cleanup( deleted } -fn load_manifest_files( +fn load_manifest( config_dir: &Path, depot_id: u32, manifest_id: u64, depot_key: &[u8], -) -> Option> { +) -> 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.files) + manifest.decrypt_filenames(depot_key).then_some(manifest) } -/// Manifest paths are normalized to '/' separators by decrypt_filenames; only -/// plain relative paths that stay inside the install dir are accepted. -fn sanitized_relative(rel: &str) -> Option<&str> { - if rel.is_empty() || rel.starts_with('/') || rel.contains(':') { - return None; +/// 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 mut components = rel.split('/'); - if components - .clone() - .any(|part| part.is_empty() || part == "." || part == "..") - { + 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; } - if components - .next() - .is_some_and(|first| first.eq_ignore_ascii_case(".DepotDownloader")) - { + 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(rel) + Some(parts) } fn delete_stale_file(path: &Path) -> bool { @@ -264,6 +429,23 @@ fn sibling_original_backup(path: &Path) -> PathBuf { 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) { @@ -340,6 +522,16 @@ mod tests { .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(); @@ -379,6 +571,32 @@ mod tests { 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"); @@ -426,12 +644,14 @@ mod tests { } #[test] - fn keeps_files_that_moved_to_another_depot() { - let install = temp_dir("multi_depot"); + 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_manifest(&config, 100, 444, &[("shared.dat", 0), ("only_old.dat", 0)]); - write_manifest(&config, 100, 555, &[("core.dat", 0)]); - write_manifest(&config, 200, 777, &[("Shared.dat", 0)]); + 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"); @@ -439,100 +659,212 @@ mod tests { 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), spec(200, 777)], - ); + let deleted = run_stale_file_cleanup(install.to_str().unwrap(), &config, &[spec(100, 555)]); assert_eq!(deleted, 1); - assert!(install.join("shared.dat").exists(), "moved depots keep file"); + 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 missing_old_manifest_drops_marker_without_deleting() { - let install = temp_dir("missing_old"); + 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, 555, &[("core.dat", 0)]); + 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_list_degrades_to_best_effort() { + let install = temp_dir("best_effort_union"); + 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"); - touch(&install, "mystery.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("mystery.dat").exists()); + assert_eq!(deleted, 1); + 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 incomplete_keep_union_defers_and_keeps_marker() { - let install = temp_dir("incomplete_union"); + fn in_progress_depot_defers_whole_pass() { + let install = temp_dir("in_progress_defer"); let config = config_dir(&install); - write_manifest(&config, 100, 444, &[("only_old.dat", 0)]); - write_manifest(&config, 100, 555, &[("core.dat", 0)]); + 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); // installed depot with no cached manifest + let mut cfg = DepotConfigStore::load(&config); + cfg.begin_depot(200); touch(&install, "only_old.dat"); assert!(record_pending_cleanup(&config, 100, 444, 555)); - // Depot 200's manifest cache is missing → whole pass defers. - let deleted = run_stale_file_cleanup( - install.to_str().unwrap(), - &config, - &[spec(100, 555), spec(200, 777)], - ); + 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_eq!(pending_cleanup_markers(&config), vec![(100, 444)]); + 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)); - // Same when the key for an installed depot is absent from this op. - write_manifest(&config, 200, 777, &[("dlc.dat", 0)]); let deleted = run_stale_file_cleanup(install.to_str().unwrap(), &config, &[spec(100, 555)]); - assert_eq!(deleted, 0); - assert_eq!(pending_cleanup_markers(&config), vec![(100, 444)]); + 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_relative("bin/game.exe"), Some("bin/game.exe")); - assert_eq!(sanitized_relative(""), None); - assert_eq!(sanitized_relative("/etc/passwd"), None); - assert_eq!(sanitized_relative("../outside.dat"), None); - assert_eq!(sanitized_relative("bin/../../outside.dat"), None); - assert_eq!(sanitized_relative("bin//double.dat"), None); - assert_eq!(sanitized_relative("c:/windows/system32"), None); - assert_eq!(sanitized_relative(".DepotDownloader/depot.config"), None); - assert_eq!(sanitized_relative(".depotdownloader/depot.config"), None); + 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_with_its_primary() { + 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)]); + 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, 2); + 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] 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 700c89d9b..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 @@ -81,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") } @@ -208,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; }; @@ -264,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 8b33630a3..a2545ff12 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,6 +1,9 @@ use crate::cdn_client::{CdnClient, CdnManifestResult}; use crate::content_manifest::ContentManifest; -use crate::depot_cleanup::{record_pending_cleanup, run_stale_file_cleanup}; +use crate::depot_cleanup::{ + backfill_filelist_sidecar, 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; @@ -325,7 +328,10 @@ pub fn download_resolved_depots_with_cancel_progress( depot.manifest_id, ); } - cfg.discard(); + // 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); @@ -350,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; } @@ -416,19 +425,12 @@ pub fn download_resolved_depots_with_cancel_progress( 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, @@ -452,6 +454,7 @@ pub fn download_resolved_depots_with_cancel_progress( )); } + let _ = write_filelist_sidecar(&config_dir, depot.depot_id, depot.manifest_id, &manifest); if !cfg.finish_depot(depot.depot_id, depot.manifest_id) { return DepotDownloadResult::fail(format!( "download: depot.config finish failed for depot {}", @@ -682,6 +685,63 @@ 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 branch_switch_deletes_files_absent_from_new_manifest() { let dir = temp_dir("branch_switch_stale_files"); diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index a21738034..44faaf6dd 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -7758,7 +7758,9 @@ class SteamService : Service() { * 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. + * 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 { @@ -7766,9 +7768,11 @@ class SteamService : Service() { if (installedManifests.isEmpty()) return val depotDownloaderDir = File(appDirPath, ".DepotDownloader") depotDownloaderDir - .listFiles { f -> f.isFile && f.name.endsWith(".manifest") } + .listFiles { f -> + f.isFile && (f.name.endsWith(".manifest") || f.name.endsWith(".filelist")) + } ?.forEach { f -> - val stem = f.name.removeSuffix(".manifest") + val stem = f.name.substringBeforeLast('.') val parts = stem.split('_') if (parts.size != 2) return@forEach val depotId = parts[0].toIntOrNull() ?: return@forEach @@ -7779,12 +7783,16 @@ class SteamService : Service() { Timber.i("Pruned stale depot manifest cache ${f.name} at $appDirPath") } } - // Markers whose manifest cache is gone can never be acted on. + // 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") - if (!File(depotDownloaderDir, "$stem.manifest").isFile && f.delete()) { + 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") } } From 43bf1eff2ffb87440d42dd3a35f7de302243399e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 12 Jun 2026 19:07:30 +0000 Subject: [PATCH 20/25] feat(steam): restore last committed branch when a switch download is cancelled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A cancelled branch-switch update left the disk a mix of the old build plus partial new-build files, selectedBranch pointing at a build that never landed, and no committed manifest the stale-file pass could diff the leftovers against (the documented last limitation). Native: the target build's .filelist sidecar is written before any game file is touched, and a failed/cancelled depot write records an aborted- build .stalecleanup marker for the target gid. The marker resolves like any other — dropped if the build later commits (resume), otherwise the next successful download diffs the aborted build's files away. Kotlin: picking a branch records the replaced selection as a previousBranch shortcut extra (first un-committed switch only; recovery heal and the restore itself don't record). Cancelling an update task reverts selectedBranch to it, clears the restore point, toasts, and — when files were already touched — enqueues the verify flow, which resolves the now-restored branch and repairs mismatched chunks; the aborted-build marker then reclaims the orphans. A committed download clears the restore point. Safety prerequisite: cancelRunning treated only TASK_UPDATE as an update, so a cancelled TASK_VERIFY fell through to the install-cancel branch and deleted the game directory. VERIFY now cleans up like an update — required since the recovery auto-enqueues verifies, and cancelling a verify of an installed game must never delete it. https://claude.ai/code/session_01RPe9jUg4EkHe7in3RzDra3 --- .../wn-steam-client/rust/src/depot_cleanup.rs | 65 ++++++++++- .../rust/src/depot_downloader.rs | 110 +++++++++++++++++- .../stores/steam/service/SteamService.kt | 79 ++++++++++++- app/src/main/res/values/strings.xml | 1 + 4 files changed, 248 insertions(+), 7 deletions(-) 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 index b756f6e03..28a563cc8 100644 --- 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 @@ -135,14 +135,30 @@ pub fn record_pending_cleanup( { return false; } - let path = stale_cleanup_marker_path(config_dir, depot_id, old_manifest_id); + 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, old_manifest_id.to_string()).is_ok() + fs::write(path, manifest_id.to_string()).is_ok() } pub fn pending_cleanup_markers(config_dir: impl AsRef) -> Vec<(u32, u64)> { @@ -867,6 +883,51 @@ mod tests { 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"); 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 a2545ff12..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,8 +1,8 @@ use crate::cdn_client::{CdnClient, CdnManifestResult}; use crate::content_manifest::ContentManifest; use crate::depot_cleanup::{ - backfill_filelist_sidecar, record_pending_cleanup, run_stale_file_cleanup, - write_filelist_sidecar, + 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}; @@ -420,6 +420,9 @@ 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; @@ -448,14 +451,18 @@ 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 )); } - let _ = write_filelist_sidecar(&config_dir, depot.depot_id, depot.manifest_id, &manifest); 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 @@ -742,6 +749,103 @@ mod tests { 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"); diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 44faaf6dd..581cef99f 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -2432,6 +2432,7 @@ class SteamService : Service() { context: Context, appId: Int, branchName: String, + recordPrevious: Boolean = true, ): Boolean { var shortcut = findSteamShortcut(context, appId) if (shortcut == null) { @@ -2442,6 +2443,17 @@ class SteamService : Service() { 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 @@ -4601,6 +4613,12 @@ class SteamService : Service() { 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) + } + // Same reason as the runCatching above: a Room exception // here used to FAIL a fully-downloaded game with COMPLETE // marker already on disk. @@ -5632,7 +5650,7 @@ class SteamService : Service() { // 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)) { + if (setSelectedBetaBranch(svc, appId, inferred, recordPrevious = false)) { Timber.i("Recovered beta branch '$inferred' for appId=$appId from the installed game dir") } inferred @@ -7837,6 +7855,54 @@ class SteamService : Service() { } } + /** + * A cancelled update may have been switching beta branches, leaving + * selectedBranch pointing at a build that never landed. Restore the + * last committed selection (recorded as the "previousBranch" extra + * when the picker changed it) and — when the cancelled download had + * already touched files — run the verify flow: it resolves the (now + * restored) branch at download time and repairs mismatched chunks, + * after which the native stale-file pass reclaims files unique to the + * aborted build via its ".stalecleanup" marker. No-op for updates that + * weren't a branch switch. + */ + private fun restoreCommittedBranchAfterCancelledUpdate( + appId: Int, + verifyFiles: Boolean, + ) { + val svc = instance ?: return + runCatching { + val previous = + readSteamShortcutExtras(svc, appId)?.first?.get("previousBranch").orEmpty().trim() + if (previous.isEmpty()) return + val restored = if (previous.equals("public", ignoreCase = true)) "" else previous + if (!setSelectedBetaBranch(svc, appId, restored, recordPrevious = false)) return + clearPreviousBetaBranch(svc, appId) + Timber.i( + "Cancelled update for appId=$appId: restored beta branch '$previous'" + + if (verifyFiles) ", verifying files" else "", + ) + WinToast.show( + svc.applicationContext, + svc.getString(R.string.store_game_beta_branch_restoring, previous), + Toast.LENGTH_LONG, + ) + if (verifyFiles) downloadAppForVerify(appId) + }.onFailure { e -> + Timber.w(e, "Beta branch restore after cancelled update failed for appId=$appId") + } + } + + 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") } + } + suspend fun checkDlcOwnershipViaPICSBatch(dlcAppIds: Set): Set { if (dlcAppIds.isEmpty()) return emptySet() @@ -7944,7 +8010,12 @@ 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) { @@ -7968,6 +8039,10 @@ class SteamService : Service() { } info?.updateStatus(DownloadPhase.CANCELLED) removeDownloadJob(appId, forceRemove = true) + restoreCommittedBranchAfterCancelledUpdate( + appId, + verifyFiles = !updateNeverStarted, + ) return@launch } val dirFile = java.io.File(appDirPath) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 23231ffe9..28bf26018 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -120,6 +120,7 @@ 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 From b3a0cfaf511258f337343235b4642f828c6e40b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 14:11:07 +0000 Subject: [PATCH 21/25] fix(steam): converge cancelled-switch recovery; label + guard the restore download MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Testing exposed that the cancelled-switch restore left the install desynced (wrong file sizes, "no updates" on a different branch) — mostly because the restore verify was unlabelled and freely cancellable, so it got aborted mid-repair. This round makes the recovery converge and gives the restore a first-class, guarded UX. Recovery convergence: - checkForAppUpdate no longer trusts a stale cached .manifest when depot.config exists but lacks the depot's entry (the false "no updates"); only true legacy installs with no depot.config use the cache fallback. - pruneInProgressDepotConfigEntries drops INVALID_MANIFEST_ID entries a cancelled begin_depot left behind, on every cancel path, so update detection and the branch selector reflect committed depots only. - previousBranch is kept until the repair COMPLETES (cleared in completeAppDownload), doubling as the durable "restore in progress" signal across requeue/restart. - cancelRunning now distinguishes UPDATE cancel (revert + repair), restore-verify cancel (abandon to not-installed, files kept for re-download, no re-arm), and normal-verify cancel (stays installed). Never-launched beta: - persistInstalledBranchName records branch_name in configs.app.ini at download completion, so an install whose build no longer matches any branch's current PICS manifest is still recoverable after a WinNative reinstall (launch later rewrites the file in full). Restore UX (reuses existing Material 3 surfaces): - DownloadInfo.isRepair drives a "Restoring" status label and a repair-specific cancel-warning dialog (cancelling means re-downloading from the store) via the existing LaunchDangerConfirmDialog. Cleanup: - Removed the dead staging-restore block from cleanupCancelledUpdate (the depot writer patches in place and never populates .DepotDownloader/staging). https://claude.ai/code/session_01RPe9jUg4EkHe7in3RzDra3 --- app/src/main/app/shell/UnifiedActivity.kt | 38 ++- .../feature/stores/steam/data/DownloadInfo.kt | 5 + .../stores/steam/service/SteamService.kt | 218 +++++++++++++----- app/src/main/res/values/strings.xml | 4 + 4 files changed, 206 insertions(+), 59 deletions(-) diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 980023482..4bb05347b 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -8522,10 +8522,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), @@ -8708,8 +8724,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)) @@ -8860,6 +8889,7 @@ class UnifiedActivity : showDeleteDialog = false DownloadService.cancelDownload(id) }, + isRepair = info.isRepair, ) } } 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/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 581cef99f..52e1fc7bf 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -2661,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), @@ -2669,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() @@ -4618,6 +4618,11 @@ class SteamService : Service() { 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 @@ -7632,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, @@ -7647,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 } } @@ -7817,58 +7833,33 @@ class SteamService : Service() { }.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) - - val stagingDir = File(File(appDirPath, ".DepotDownloader"), "staging") - if (!stagingDir.exists()) return - - stagingDir - .walkBottomUp() - .forEach { staged -> - if (staged == stagingDir) return@forEach - if (staged.isDirectory) { - if (staged.list().isNullOrEmpty()) staged.delete() - return@forEach - } - - 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() - } - }.onFailure { - Timber.w(it, "Failed to restore staged Steam update file ${staged.absolutePath}") - } - } - - if (stagingDir.exists() && stagingDir.list().isNullOrEmpty()) { - stagingDir.delete() - } } /** - * A cancelled update may have been switching beta branches, leaving - * selectedBranch pointing at a build that never landed. Restore the - * last committed selection (recorded as the "previousBranch" extra - * when the picker changed it) and — when the cancelled download had - * already touched files — run the verify flow: it resolves the (now - * restored) branch at download time and repairs mismatched chunks, - * after which the native stale-file pass reclaims files unique to the - * aborted build via its ".stalecleanup" marker. No-op for updates that - * weren't a branch switch. + * 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, - verifyFiles: Boolean, + filesTouched: Boolean, ) { val svc = instance ?: return runCatching { @@ -7877,22 +7868,56 @@ class SteamService : Service() { if (previous.isEmpty()) return val restored = if (previous.equals("public", ignoreCase = true)) "" else previous if (!setSelectedBetaBranch(svc, appId, restored, recordPrevious = false)) return - clearPreviousBetaBranch(svc, appId) - Timber.i( - "Cancelled update for appId=$appId: restored beta branch '$previous'" + - if (verifyFiles) ", verifying files" else "", - ) + + 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 + } + + // Files were touched. Keep previousBranch as the restore-in-progress + // signal until the repair completes. Drop any INVALID depot.config + // entries so update detection / the selector reflect committed depots + // only while the repair runs. + pruneInProgressDepotConfigEntries(getAppDirPath(appId)) + 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 (verifyFiles) downloadAppForVerify(appId) + if (downloadAppForVerify(appId, isRestore = true) == null) { + Timber.w("Restore verify dispatch returned null for appId=$appId; coordinator will retry on next tick") + } }.onFailure { e -> Timber.w(e, "Beta branch restore after cancelled update failed for appId=$appId") } } + /** + * 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, drop the restore point and any INVALID depot.config entries. + */ + private fun abandonInProgressRestore(appId: Int, appDirPath: String) { + instance?.let { clearPreviousBetaBranch(it, appId) } + pruneInProgressDepotConfigEntries(appDirPath) + 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") + } + + 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 @@ -7903,6 +7928,67 @@ class SteamService : Service() { }.onFailure { e -> Timber.w(e, "Failed to clear previousBranch for appId=$appId") } } + /** + * Rewrites `.DepotDownloader/depot.config` dropping INVALID_MANIFEST_ID + * (Long.MAX_VALUE) entries a cancelled `begin_depot` left behind, so + * consumers (update detection, branch selector, stale-file keep-union) + * see only cleanly committed depots. A subsequent fresh verify rebuilds + * the dropped depots. + */ + private fun pruneInProgressDepotConfigEntries(appDirPath: String) { + runCatching { + val configFile = File(File(appDirPath, ".DepotDownloader"), "depot.config") + if (!configFile.isFile) return + val json = JSONObject(configFile.readText()) + val manifests = json.optJSONObject("installedManifestIDs") ?: return + val kept = JSONObject() + var dropped = 0 + for (key in manifests.keys()) { + val gid = manifests.optLong(key, Long.MAX_VALUE) + if (gid == Long.MAX_VALUE) dropped++ else kept.put(key, gid) + } + if (dropped == 0) return + json.put("installedManifestIDs", kept) + configFile.writeText(json.toString(2)) + Timber.i("Pruned $dropped in-progress depot.config entr${if (dropped == 1) "y" else "ies"} at $appDirPath") + }.onFailure { e -> Timber.w(e, "Failed to prune in-progress depot.config entries for $appDirPath") } + } + + /** + * 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") + } + } + ini.writeText(lines.joinToString("\n", postfix = "\n")) + } else { + ini.writeText("[app::general]\nbranch_name=$branch\n") + } + 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 { if (dlcAppIds.isEmpty()) return emptySet() @@ -7979,7 +8065,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) } @@ -8019,6 +8108,11 @@ class SteamService : Service() { 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 || ( @@ -8039,10 +8133,24 @@ class SteamService : Service() { } info?.updateStatus(DownloadPhase.CANCELLED) removeDownloadJob(appId, forceRemove = true) - restoreCommittedBranchAfterCancelledUpdate( - appId, - verifyFiles = !updateNeverStarted, - ) + when { + isRestoreVerify -> + // User aborted the restore — converge to not-installed. + abandonInProgressRestore(appId, appDirPath) + isVerify -> { + // Normal verify of an installed game: keep it installed. + pruneInProgressDepotConfigEntries(appDirPath) + 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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 28bf26018..5c69fb110 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -269,6 +269,7 @@ Finalizing Unpacking Updating + Restoring Complete Cancelled Failed: %1$s @@ -280,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. From f35ea7cb48865d15a0e14f867e1111078d25afd8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 14:21:50 +0000 Subject: [PATCH 22/25] review(steam): drop unsafe depot.config rewrite; fix restore null-dispatch + queue cancel warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-angle review fixes for the cancelled-switch recovery: - HIGH: pruneInProgressDepotConfigEntries could delete a valid depot. Steam manifest GIDs with the high bit set read back as Long.MAX_VALUE through org.json's optLong (u64 parsed as Double, saturated), making a legitimate entry indistinguishable from INVALID_MANIFEST_ID — and a rewrite would corrupt it. Removed the Kotlin depot.config rewrite entirely; the native fresh verify's discard_depots already converges state with exact u64 handling, and checkForAppUpdate already treats a lingering INVALID/missing entry as needs-download. - MED: a null restore-verify dispatch (e.g. transient depot-resolution failure, before any coordinator record exists) left previousBranch dangling forever, which would later restore the WRONG branch. Now abandons cleanly to not-installed on null dispatch. - MED: the queue-screen cancel button showed the generic delete warning for an in-progress restore; thread isRepair through DownloadCancelRequest so it shows the restore-specific warning there too. https://claude.ai/code/session_01RPe9jUg4EkHe7in3RzDra3 --- app/src/main/app/shell/UnifiedActivity.kt | 6 +++ .../stores/steam/service/SteamService.kt | 45 +++++-------------- 2 files changed, 17 insertions(+), 34 deletions(-) diff --git a/app/src/main/app/shell/UnifiedActivity.kt b/app/src/main/app/shell/UnifiedActivity.kt index 4bb05347b..24a1cae8a 100644 --- a/app/src/main/app/shell/UnifiedActivity.kt +++ b/app/src/main/app/shell/UnifiedActivity.kt @@ -7813,6 +7813,7 @@ class UnifiedActivity : private data class DownloadCancelRequest( val ids: List, val isCancelAll: Boolean, + val isRepair: Boolean = false, ) // Downloads Tab @@ -7989,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, ) } }, @@ -8017,6 +8022,7 @@ class UnifiedActivity : onSelectDownload(null) }, isCancelAll = request.isCancelAll, + isRepair = request.isRepair, ) } } diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index 52e1fc7bf..f9213bc31 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -7878,10 +7878,8 @@ class SteamService : Service() { } // Files were touched. Keep previousBranch as the restore-in-progress - // signal until the repair completes. Drop any INVALID depot.config - // entries so update detection / the selector reflect committed depots - // only while the repair runs. - pruneInProgressDepotConfigEntries(getAppDirPath(appId)) + // 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, @@ -7889,7 +7887,11 @@ class SteamService : Service() { Toast.LENGTH_LONG, ) if (downloadAppForVerify(appId, isRestore = true) == null) { - Timber.w("Restore verify dispatch returned null for appId=$appId; coordinator will retry on next tick") + // 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") @@ -7901,11 +7903,11 @@ class SteamService : Service() { * 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, drop the restore point and any INVALID depot.config entries. + * 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) } - pruneInProgressDepotConfigEntries(appDirPath) 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") @@ -7928,32 +7930,6 @@ class SteamService : Service() { }.onFailure { e -> Timber.w(e, "Failed to clear previousBranch for appId=$appId") } } - /** - * Rewrites `.DepotDownloader/depot.config` dropping INVALID_MANIFEST_ID - * (Long.MAX_VALUE) entries a cancelled `begin_depot` left behind, so - * consumers (update detection, branch selector, stale-file keep-union) - * see only cleanly committed depots. A subsequent fresh verify rebuilds - * the dropped depots. - */ - private fun pruneInProgressDepotConfigEntries(appDirPath: String) { - runCatching { - val configFile = File(File(appDirPath, ".DepotDownloader"), "depot.config") - if (!configFile.isFile) return - val json = JSONObject(configFile.readText()) - val manifests = json.optJSONObject("installedManifestIDs") ?: return - val kept = JSONObject() - var dropped = 0 - for (key in manifests.keys()) { - val gid = manifests.optLong(key, Long.MAX_VALUE) - if (gid == Long.MAX_VALUE) dropped++ else kept.put(key, gid) - } - if (dropped == 0) return - json.put("installedManifestIDs", kept) - configFile.writeText(json.toString(2)) - Timber.i("Pruned $dropped in-progress depot.config entr${if (dropped == 1) "y" else "ies"} at $appDirPath") - }.onFailure { e -> Timber.w(e, "Failed to prune in-progress depot.config entries for $appDirPath") } - } - /** * Records the installed branch in `steam_settings/configs.app.ini` at * download completion so a never-launched install (whose build may no @@ -8139,7 +8115,8 @@ class SteamService : Service() { abandonInProgressRestore(appId, appDirPath) isVerify -> { // Normal verify of an installed game: keep it installed. - pruneInProgressDepotConfigEntries(appDirPath) + // (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) } From 63a81d43de39f86c0f87709638eb739e9f0bcaa6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 18:53:25 +0000 Subject: [PATCH 23/25] fix(steam): store restored branch verbatim so the selector isn't mislabelled MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restoring to public after a cancelled switch wrote a blank selectedBranch, which made recoverSelectedBetaName fall into its infer-from-installed- manifests path. On a still-mixed install that inference misidentifies the branch, so the STEAM-dropdown / store selector showed the wrong branch. Store the restored branch (including "public") verbatim — all readers normalise via ifBlank{"public"}, and reinstall recovery still triggers because a reinstall wipes the whole shortcut, not just the value. https://claude.ai/code/session_01RPe9jUg4EkHe7in3RzDra3 --- app/src/main/feature/stores/steam/service/SteamService.kt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/app/src/main/feature/stores/steam/service/SteamService.kt b/app/src/main/feature/stores/steam/service/SteamService.kt index f9213bc31..a4834f2de 100644 --- a/app/src/main/feature/stores/steam/service/SteamService.kt +++ b/app/src/main/feature/stores/steam/service/SteamService.kt @@ -7866,8 +7866,11 @@ class SteamService : Service() { val previous = readSteamShortcutExtras(svc, appId)?.first?.get("previousBranch").orEmpty().trim() if (previous.isEmpty()) return - val restored = if (previous.equals("public", ignoreCase = true)) "" else previous - if (!setSelectedBetaBranch(svc, appId, restored, recordPrevious = false)) 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 From 5f3a773b231a67326a9990e90ed8a81622c6c85e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 19:18:08 +0000 Subject: [PATCH 24/25] feat(steam): sweep all cached old builds so branch-switch leftovers are reclaimed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stale-file pass only diffed builds that had a pending .stalecleanup marker, so an aborted build whose marker was dropped/deferred left files behind after a restore. It now sweeps every cached old build (any .filelist/.manifest whose gid isn't the currently-installed one) minus the union of all current builds — reclaiming orphans regardless of marker state, while still only ever deleting files that appear in some Steam manifest. User-added mod files (in no manifest) are never touched; a file a mod overwrote is reverted by the download itself, as on real Steam. Also replaces the global abort-on-in-progress with per-depot deferral: an INVALID (mid-download) depot is protected and its old builds skipped, without blocking cleanup of the other depots. A current depot whose own file list is unreadable is likewise deferred to avoid over-deletion. https://claude.ai/code/session_01RPe9jUg4EkHe7in3RzDra3 --- .../wn-steam-client/rust/src/depot_cleanup.rs | 326 ++++++++++++------ 1 file changed, 218 insertions(+), 108 deletions(-) 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 index 28a563cc8..997e206d4 100644 --- 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 @@ -193,22 +193,20 @@ fn remove_cleanup_marker(config_dir: impl AsRef, depot_id: u32, manifest_i /// 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 the OLD manifest of -/// a depot with a pending marker, minus the union of every currently-installed -/// manifest's files (read from filelist sidecars, falling back to cached -/// manifests when this operation holds the depot's key). Files WinNative -/// itself adds (steam_settings/, .DepotDownloader/, *.original.exe backups, -/// saves) appear in no manifest and can never become candidates. The union is -/// best-effort: an installed depot whose file list is unreadable (legacy -/// install predating sidecars, key not in this op) is logged and skipped — -/// the per-depot old-minus-new diff itself never depends on it, which matches -/// the reference DepotDownloader behaviour while sidecars close the gap on -/// every download going forward. +/// 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. /// -/// Known model limitation: a download cancelled mid-write leaves no committed -/// gid to diff against on the next switch, so files unique to the aborted -/// build are not reclaimed (depot.config holds INVALID for it; the prior -/// marker resolves as already-current). +/// 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. A +/// depot whose depot.config entry is still in progress (INVALID) is deferred +/// per-depot: its files are protected and its old builds are left for a later +/// pass, without blocking cleanup of the other depots. /// /// Returns the number of files deleted. pub fn run_stale_file_cleanup( @@ -216,137 +214,206 @@ pub fn run_stale_file_cleanup( config_dir: &Path, depots: &[ResolvedDepotSpec], ) -> u32 { - let markers = pending_cleanup_markers(config_dir); - if markers.is_empty() { - return 0; - } - let keys: BTreeMap = depots .iter() .map(|depot| (depot.depot_id, depot.depot_key.as_slice())) .collect(); let cfg = DepotConfigStore::load(config_dir); - let mut keep = BTreeSet::new(); - for (depot_id, manifest_id) in cfg.installed_entries() { - if manifest_id == INVALID_MANIFEST_ID { - cleanup_log(&format!( - "cleanup: depot {depot_id} still in progress, deferring stale-file pass" - )); - return 0; + // Current committed gid per depot; an INVALID entry means that depot's + // build is mid-flight, so it is deferred this pass. + let mut current: BTreeMap = BTreeMap::new(); + let mut deferred: BTreeSet = BTreeSet::new(); + for (depot_id, gid) in cfg.installed_entries() { + if gid == INVALID_MANIFEST_ID { + deferred.insert(depot_id); + } else { + current.insert(depot_id, gid); } - match load_file_entries( - config_dir, - depot_id, - manifest_id, - keys.get(&depot_id).copied(), - ) { + } + + // 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; + } + + // Keep-union: files of every current build (so a file shared across builds + // or moved between depots is never deleted), plus — defensively — every + // cached build of a deferred depot so a mid-download depot loses nothing. + // A current depot whose own file list is unreadable is itself deferred: + // sweeping with an incomplete keep set could delete its live files. + let mut keep = BTreeSet::new(); + for (depot_id, gid) in current.clone() { + 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)); } } - None => cleanup_log(&format!( - "cleanup: no file list for installed depot {depot_id}_{manifest_id}; \ - protecting with remaining manifests only" - )), + None => { + cleanup_log(&format!( + "cleanup: installed depot {depot_id}_{gid} file list unreadable; \ + deferring it to avoid over-deletion" + )); + deferred.insert(depot_id); + current.remove(&depot_id); + } + } + } + for &(depot_id, gid) in &old_builds { + if deferred.contains(&depot_id) { + if let Some(entries) = + load_file_entries(config_dir, depot_id, gid, keys.get(&depot_id).copied()) + { + for entry in &entries { + keep.insert(normalized_key(&entry.name)); + } + } } } let install_root = Path::new(install_dir); let mut deleted = 0u32; - for (depot_id, old_gid) in markers { - if cfg.installed_manifest(depot_id) == old_gid { - // Marker for the build that is (again) current — nothing stale. - remove_cleanup_marker(config_dir, depot_id, old_gid); + for (depot_id, gid) in old_builds { + if deferred.contains(&depot_id) { + continue; + } + 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, old_gid, keys.get(&depot_id).copied()) + load_file_entries(config_dir, depot_id, gid, keys.get(&depot_id).copied()) else { - if filelist_sidecar_path(config_dir, depot_id, old_gid).is_file() + if filelist_sidecar_path(config_dir, depot_id, gid).is_file() || config_dir - .join(format!("{depot_id}_{old_gid}.manifest")) + .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}_{old_gid} unreadable in this op, deferring" + "cleanup: old file list {depot_id}_{gid} unreadable in this op, deferring" )); } else { cleanup_log(&format!( - "cleanup: old file list {depot_id}_{old_gid} is gone, dropping marker" + "cleanup: old file list {depot_id}_{gid} is gone, dropping marker" )); - remove_cleanup_marker(config_dir, depot_id, old_gid); + 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 +} - 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 {old_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; - } +/// 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()); - } + } + 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); - } - remove_cleanup_marker(config_dir, depot_id, old_gid); } - if deleted > 0 { - cleanup_log(&format!( - "cleanup: removed {deleted} stale file(s) under '{install_dir}'" - )); + 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, @@ -736,22 +803,65 @@ mod tests { } #[test] - fn in_progress_depot_defers_whole_pass() { - let install = temp_dir("in_progress_defer"); + fn in_progress_depot_is_protected_while_others_are_cleaned() { + // Per-depot deferral: an in-flight depot (200, INVALID) keeps all its + // files, but it must not block cleaning depot 100's stale file. + let install = temp_dir("in_progress_per_depot"); 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); + 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); - assert!(install.join("only_old.dat").exists()); - assert_eq!(pending_cleanup_markers(&config), vec![(100, 444)]); + assert_eq!(deleted, 1); + assert!( + !install.join("only_old.dat").exists(), + "depot 100 stale file cleaned" + ); + assert!(install.join("core.dat").exists()); + assert!( + install.join("dlc.dat").exists(), + "in-flight depot 200 protected" + ); + assert!(pending_cleanup_markers(&config).is_empty()); + 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); } From be2c3687ffac785358e885caa2a831f30061425c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 13 Jun 2026 19:27:11 +0000 Subject: [PATCH 25/25] review(steam): defer cleanup wholesale when the keep-union is incomplete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multi-angle review found a cross-depot over-deletion: the sweep's per-depot deferral protected an in-flight depot only from its own cached builds, so a file that moved from another depot's old build into an in-flight (or unreadable) depot — whose target build cleanup can't see — could be deleted as stale. Require a complete keep-union instead: if any installed depot is in progress (INVALID) or its file list is unreadable, defer the whole pass until a later op can read every depot. This never blocks the restore (the verify writes a sidecar for every depot before cleanup runs), so the leftover-reclaiming sweep still does its job there. Adds a regression test for the cross-depot-into-in-flight-depot case. https://claude.ai/code/session_01RPe9jUg4EkHe7in3RzDra3 --- .../wn-steam-client/rust/src/depot_cleanup.rs | 124 ++++++++++-------- 1 file changed, 66 insertions(+), 58 deletions(-) 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 index 997e206d4..a2073b21e 100644 --- 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 @@ -203,10 +203,10 @@ fn remove_cleanup_marker(config_dir: impl AsRef, depot_id: u32, manifest_i /// /// 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. A -/// depot whose depot.config entry is still in progress (INVALID) is deferred -/// per-depot: its files are protected and its old builds are left for a later -/// pass, without blocking cleanup of the other depots. +/// 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( @@ -220,18 +220,6 @@ pub fn run_stale_file_cleanup( .collect(); let cfg = DepotConfigStore::load(config_dir); - // Current committed gid per depot; an INVALID entry means that depot's - // build is mid-flight, so it is deferred this pass. - let mut current: BTreeMap = BTreeMap::new(); - let mut deferred: BTreeSet = BTreeSet::new(); - for (depot_id, gid) in cfg.installed_entries() { - if gid == INVALID_MANIFEST_ID { - deferred.insert(depot_id); - } else { - current.insert(depot_id, gid); - } - } - // 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. @@ -241,37 +229,36 @@ pub fn run_stale_file_cleanup( return 0; } - // Keep-union: files of every current build (so a file shared across builds - // or moved between depots is never deleted), plus — defensively — every - // cached build of a deferred depot so a mid-download depot loses nothing. - // A current depot whose own file list is unreadable is itself deferred: - // sweeping with an incomplete keep set could delete its live files. + // 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(); - for (depot_id, gid) in current.clone() { + 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 it to avoid over-deletion" + "cleanup: installed depot {depot_id}_{gid} file list unreadable, \ + deferring stale-file pass" )); - deferred.insert(depot_id); - current.remove(&depot_id); - } - } - } - for &(depot_id, gid) in &old_builds { - if deferred.contains(&depot_id) { - if let Some(entries) = - load_file_entries(config_dir, depot_id, gid, keys.get(&depot_id).copied()) - { - for entry in &entries { - keep.insert(normalized_key(&entry.name)); - } + return 0; } } } @@ -279,9 +266,6 @@ pub fn run_stale_file_cleanup( let install_root = Path::new(install_dir); let mut deleted = 0u32; for (depot_id, gid) in old_builds { - if deferred.contains(&depot_id) { - continue; - } if current.get(&depot_id) == Some(&gid) { // This build is the one currently installed — not stale. remove_cleanup_marker(config_dir, depot_id, gid); @@ -782,8 +766,11 @@ mod tests { } #[test] - fn unreadable_installed_list_degrades_to_best_effort() { - let install = temp_dir("best_effort_union"); + 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)]); @@ -795,18 +782,19 @@ mod tests { let deleted = run_stale_file_cleanup(install.to_str().unwrap(), &config, &[spec(100, 555)]); - assert_eq!(deleted, 1); - assert!(!install.join("only_old.dat").exists()); + assert_eq!(deleted, 0); + assert!(install.join("only_old.dat").exists()); assert!(install.join("core.dat").exists()); - assert!(pending_cleanup_markers(&config).is_empty()); + assert_eq!(pending_cleanup_markers(&config), vec![(100, 444)]); let _ = fs::remove_dir_all(&install); } #[test] - fn in_progress_depot_is_protected_while_others_are_cleaned() { - // Per-depot deferral: an in-flight depot (200, INVALID) keeps all its - // files, but it must not block cleaning depot 100's stale file. - let install = temp_dir("in_progress_per_depot"); + 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)]); @@ -821,17 +809,37 @@ mod tests { let deleted = run_stale_file_cleanup(install.to_str().unwrap(), &config, &[spec(100, 555)]); - assert_eq!(deleted, 1); - assert!( - !install.join("only_old.dat").exists(), - "depot 100 stale file cleaned" - ); - assert!(install.join("core.dat").exists()); + 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("dlc.dat").exists(), - "in-flight depot 200 protected" + install.join("shared.dat").exists(), + "live file shipped by the in-flight depot must survive" ); - assert!(pending_cleanup_markers(&config).is_empty()); let _ = fs::remove_dir_all(&install); }