Skip to content

feat(steam): beta branch selector in the STEAM dropdown#2

Open
Leb-Sun wants to merge 26 commits into
mainfrom
claude/steam-beta-branch-selector-ckipz0
Open

feat(steam): beta branch selector in the STEAM dropdown#2
Leb-Sun wants to merge 26 commits into
mainfrom
claude/steam-beta-branch-selector-ckipz0

Conversation

@Leb-Sun

@Leb-Sun Leb-Sun commented Jun 10, 2026

Copy link
Copy Markdown
Owner

Summary

Adds Steam beta branch support to WinNative end to end: a Workshop-styled picker reachable from the STEAM ▾ menu on installed games and from a segment "sliced off" the Download button before install. Selecting a branch persists it, retargets size estimates, and drives the download/update flow so the branch's build is what actually lands on disk. The backend (PICS branches, depot manifest resolution, setAppCurrentBeta) already existed — this PR wires the UI to it and fixes the gaps found along the way.

Diff note: based on claude/winnative-steam-launch-options-0oluek (PR #1). Merge #1 first; this PR then collapses to the beta-branch work.


Features

Beta Branch picker (installed games)

  • "Beta Branch" item in the STEAM ▾ menu on both game-detail screens (hidden when a game has fewer than 2 branches); AltRoute icon
  • Workshop-styled window (BetaBranchScreen.kt): branch name (public (default) labeled), build id + updated date, check mark on the active branch, password-protected branches shown locked/disabled
  • Selecting persists to the shortcut and triggers the update flow so the branch's build downloads

Note: switching the branch immediately after exiting a game can fail — Steam is still shutting down cleanly in the background and won't release the game's files yet. This is normal; wait a few seconds and pick the branch again.

Pre-download branch selection (store tab)

  • The Download pill gets a 48 dp glass cutout segment that opens the same picker before install; picks are staged in memory and committed on Download. Epic/GOG and single-branch games show the normal full-width button.

Reinstall / never-launched recovery

recoverSelectedBetaName infers the branch from the surviving game dir after a WinNative reinstall. The installed branch is now also written to steam_settings/configs.app.ini at download completion, so even a never-launched install (whose build may no longer match any branch's current PICS manifest) recovers correctly.

Stale-file cleanup on branch switch — mod-safe sweep

The native depot writer only writes new/changed files, so switching branches would leave files the new branch no longer ships. depot_cleanup.rs reclaims them after a successful download by sweeping every cached old build (any .filelist/.manifest whose gid isn't the currently-installed one) and deleting its files minus the union of all current builds.

  • Mod-safe: only ever deletes 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 — same as real Steam's "Verify Integrity".
  • Sweeping cached builds (not just ones with a marker) means orphans are reclaimed even if a marker was dropped/never recorded — this is what fixes leftover files after a cancelled-switch restore.
  • Per-depot deferral: a mid-download (INVALID) depot is protected and its old builds skipped without blocking the others; a depot whose own file list is unreadable is deferred to avoid over-deletion.
  • Decrypted .filelist sidecars (written before any file is touched) make it key-independent, so it works in narrowed updates and per-app DLC batches. Candidates reject ../absolute paths and skip symlinked-directory ancestors; per-batch fresh downloads don't wipe other batches' depot.config.

Cancelled-switch recovery (restore last committed branch)

WinNative patches files in place, so a branch-switch download cancelled mid-write leaves a half-old/half-new mix. Recovery converges it:

  • Picking a branch records the replaced selection as a previousBranch extra; on cancel, the selection reverts to it (stored verbatim, including "public", so the selector reads it authoritatively instead of guessing from mixed files) and a labelled "Restoring" download repairs the files back, after which the sweep removes the abandoned branch's leftovers.
  • The restore is a first-class download: "Restoring" status and a cancel-warning dialog (cancelling leaves the game unplayable and requires re-downloading from the store) on both the per-item and queue cancel buttons, reusing the app's Material 3 confirm surface. Cancelling it converges cleanly to not-installed (files kept for re-download), never a corrupt mix.
  • previousBranch is held until the repair completes (survives requeue/restart, reliably labels the restore); a committed download clears it. Update detection no longer trusts a stale cached manifest when depot.config is present but incomplete (fixes a false "no updates").
  • Safety fix: cancelRunning classified only TASK_UPDATE as an update, so cancelling a TASK_VERIFY fell through to the install-cancel branch and deleted the game directory; verify now cleans up like an update.

Fixes shipped along the way

  • configs.app.ini [app::general] reflects the selected branch (was hardcoded branch_name=public)
  • checkForAppUpdate/downloadApp honor the selected branch; stale {depot}_{gid}.manifest/.filelist caches pruned after completed downloads
  • Lightweight .desktop parser for shortcut extras in hot paths instead of loadShortcuts()
  • VerifyError fix: StoreGameDetailScreen split into a thin wrapper + content method (a ~260-register method made D8 emit bytecode some vendor ART verifiers reject — instant crash opening any store game)
  • Cancelling a Verify Files task no longer deletes the install directory

Known limitations (documented in code)

  • No beta-password (CheckAppBetaPassword) support; protected branches are visible but locked

Test checklist

  • Switch branch on an installed game → update downloads the branch's build; check mark persists
  • Reinstall WinNative → branch-switched game keeps its beta; never-launched beta install recovers too
  • Single-branch games, Epic/GOG: unchanged UI
  • Rust: cargo test green (191 tests: stale-file sweep incl. marker-free reclamation, mod preservation, multi-depot moves, per-depot deferral, path-safety/symlink skips, aborted-switch recovery)
  • Device: cancel a branch switch mid-download → "Restoring" label + restore warning on cancel; let it finish → game back on the previous branch, no leftover files, mods intact; selecting another branch shows an update (no false "no updates")

claude added 25 commits June 19, 2026 00:50
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
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
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
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
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
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
…ownload hot paths

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
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
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
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
…een signature

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
…rifier

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
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
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
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
…ecovery

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
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
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
…en deletion

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
…cancelled

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
…tore download

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
…patch + queue cancel warning

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
…abelled

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
…re reclaimed

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
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
@Leb-Sun Leb-Sun force-pushed the claude/steam-beta-branch-selector-ckipz0 branch from 4d287bd to be2c368 Compare June 19, 2026 00:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants