diff --git a/CreamInstaller/Components/CustomTreeView.cs b/CreamInstaller/Components/CustomTreeView.cs index a004def..0b75d7b 100644 --- a/CreamInstaller/Components/CustomTreeView.cs +++ b/CreamInstaller/Components/CustomTreeView.cs @@ -255,6 +255,23 @@ internal sealed class CustomTreeView : TreeView TextRenderer.DrawText(graphics, text, font, point, color, TextFormatFlags.Default); } + // Draw "NEW" tag after the AppID for newly discovered DLCs + if (dlcType is not DLCType.None) + { + SelectionDLC dlc = SelectionDLC.FromId(dlcType, node.Parent?.Name, id); + if (dlc?.IsNew == true) + { + const string newTag = " NEW"; + size = TextRenderer.MeasureText(graphics, newTag, font); + bounds = bounds with { X = bounds.X + bounds.Width + 1, Width = size.Width }; + selectionBounds = new(selectionBounds.Location, + selectionBounds.Size + new Size(bounds.Size.Width + 1, 0)); + graphics.FillRectangle(brush, bounds); + point = new(bounds.Location.X - 1, bounds.Location.Y + 1); + TextRenderer.DrawText(graphics, newTag, font, point, Color.Orange, TextFormatFlags.Default); + } + } + if (form is MainForm) { Selection selection = Selection.FromId(platform, id); diff --git a/CreamInstaller/Forms/InstallForm.cs b/CreamInstaller/Forms/InstallForm.cs index 2712a32..b4a8ea7 100644 --- a/CreamInstaller/Forms/InstallForm.cs +++ b/CreamInstaller/Forms/InstallForm.cs @@ -371,27 +371,7 @@ internal sealed partial class InstallForm : CustomForm if (unlocker != InstalledUnlocker.None) { int dlcCount = selection.DLC.Count(); - ProgramData.UpsertInstalledGame(new InstalledGameRecord - { - Platform = selection.Platform, - Id = selection.Id, - Name = selection.Name, - RootDirectory = selection.RootDirectory, - Unlocker = unlocker, - UseProxy = selection.UseProxy, - ProxyDllName = selection.UseProxy ? selection.Proxy ?? Selection.DefaultProxy : null, - UseExtraProtection = selection.UseExtraProtection, - Dlc = selection.DLC - .GroupBy(dlc => dlc.Id) - .Select(g => g.First()) - .Select(dlc => new InstalledDlcRecord - { - DlcType = dlc.Type.ToString(), - Id = dlc.Id, - Name = dlc.Name, - Enabled = dlc.Enabled - }).ToList() - }); + ProgramData.UpsertInstalledGame(selection.ToInstalledGameRecord()); ProgramData.Log.Info($"[InstallForm] Saved to installed.json: {unlocker} with {dlcCount} DLCs | Game: {selection.Name} ({selection.Id})", LogDestination.Unlocker); } else diff --git a/CreamInstaller/Forms/MainForm.cs b/CreamInstaller/Forms/MainForm.cs index 85537af..2988726 100644 --- a/CreamInstaller/Forms/MainForm.cs +++ b/CreamInstaller/Forms/MainForm.cs @@ -36,6 +36,9 @@ internal sealed partial class MainForm : CustomForm private List<(Platform platform, string id, string name)> programsToScan; + private const int SteamCmdTimeoutMs = 16000; + private const string DlcRefreshLogPrefix = "[DLCRefresh] "; + private MainForm() { InitializeComponent(); @@ -129,6 +132,15 @@ internal sealed partial class MainForm : CustomForm return await task; return default; } + + private static async Task ResolveSteamDlcName(string dlcId, string parentGameName = null, string parentGameId = null) + { + StoreAppData dlcStore = await SteamStore.QueryStoreAPI(dlcId, isDlc: true, attempts: 0, parentGameName, parentGameId); + if (dlcStore?.Name is not null) + return dlcStore.Name; + CmdAppData dlcCmd = await SteamCMD.GetAppInfo(dlcId); + return dlcCmd?.Common?.Name ?? "Unknown"; + } private async Task GetApplicablePrograms(IProgress progress, bool uninstallAll = false) { if (!uninstallAll && (programsToScan is null || programsToScan.Count < 1)) @@ -256,7 +268,7 @@ internal sealed partial class MainForm : CustomForm _ = Interlocked.Decrement(ref steamGamesToCheck); if (Volatile.Read(ref steamGamesToCheck) == 0) gameQueriesDone.TrySetResult(); - CmdAppData cmdAppData = await WithTimeout(SteamCMD.GetAppInfo(appId, branch, buildId), 16000); + CmdAppData cmdAppData = await WithTimeout(SteamCMD.GetAppInfo(appId, branch, buildId), SteamCmdTimeoutMs); if (storeAppData is null && cmdAppData is null) { ProgramData.Log.Info($"[Steam] Skipping {name} ({appId}): no store data from Steam Store or SteamCMD — unable to determine DLCs", LogDestination.Scan); @@ -307,7 +319,7 @@ internal sealed partial class MainForm : CustomForm } else { - CmdAppData dlcCmdAppData = await WithTimeout(SteamCMD.GetAppInfo(dlcAppId), 16000); + CmdAppData dlcCmdAppData = await SteamCMD.GetAppInfo(dlcAppId); if (dlcCmdAppData is not null) { dlcName = dlcCmdAppData.Common?.Name; @@ -351,7 +363,7 @@ internal sealed partial class MainForm : CustomForm if (Program.Canceled) return; - if (!string.IsNullOrWhiteSpace(fullGameName) && fullGameAppId != dlcAppId && !SelectionDLC.All.Keys.Any(d => d.GameId == appId && d.Id == fullGameAppId)) + if (!string.IsNullOrWhiteSpace(fullGameName) && fullGameAppId != dlcAppId && !Selection.FromId(Platform.Steam, appId)?.DLCById.ContainsKey(fullGameAppId) == true) { SelectionDLC fullGameDlc = SelectionDLC.GetOrCreate( fullGameOnSteamStore ? DLCType.Steam : DLCType.SteamHidden, appId, @@ -799,8 +811,37 @@ internal sealed partial class MainForm : CustomForm await SteamCMD.Cleanup(); } - LoadSelections(); - await LoadSavedInstalledGames(); + if (!scan) + { + int loadSteps = 0; + int loadStep = 0; + Progress loadProgress = new(); + loadProgress.ProgressChanged += (_, p) => + { + if (p < 0) loadSteps = -p; + else loadStep = p; + int pc = loadSteps > 0 ? (int)((float)loadStep / loadSteps * 100) : 0; + progressBar.Value = Math.Max(0, Math.Min(pc, 100)); + progressLabel.Text = $"Loading games and DLCs from cached data... {pc}%"; + }; + IProgress loadReporter = loadProgress; + loadReporter.Report(-4); + + LoadSelections(); + loadReporter.Report(1); + + await LoadSavedInstalledGames(); + loadReporter.Report(2); + + SyncInstallerConfigs(); + loadReporter.Report(3); + } + else + { + LoadSelections(); + await LoadSavedInstalledGames(); + SyncInstallerConfigs(); + } if (!scan && Selection.All.Keys.Any(s => s.InstalledUnlocker != InstalledUnlocker.None)) RefreshNewDLCsForInstalledGames(); HideProgressBar(); @@ -957,16 +998,17 @@ internal sealed partial class MainForm : CustomForm _ = items.Add(new ToolStripSeparator()); foreach (ContextMenuItem query in queries) _ = items.Add(query); - _ = items.Add(new ContextMenuItem("Refresh Queries", "Command Prompt", (_, _) => + bool isGameNode = selection is not null; + _ = items.Add(new ContextMenuItem(isGameNode ? "Refresh Game Data" : "Refresh DLC Data", "Command Prompt", async (_, _) => { appInfoVDF.DeleteFile(); appInfoCmdJSON.DeleteFile(); appInfoJSON.DeleteFile(); cooldown.DeleteFile(); - selection?.Remove(); - if (dlc is not null) - dlc.Selection = null; - OnLoad(true); + if (isGameNode) + await RefreshSingleGameData(selection); + else + await RefreshSingleDlcData(dlc); })); } } @@ -1158,12 +1200,10 @@ internal sealed partial class MainForm : CustomForm if (selection.TreeNode.TreeView is null) _ = selectionTreeView.Nodes.Add(selection.TreeNode); - // Restore DLC children from saved record, deduplicating by ID + // Restore DLC children from saved record if (record.Dlc != null && record.Dlc.Count > 0) { - foreach (InstalledDlcRecord dlcRecord in record.Dlc - .GroupBy(d => d.Id) - .Select(g => g.First())) + foreach (InstalledDlcRecord dlcRecord in record.Dlc) { if (!Enum.TryParse(dlcRecord.DlcType, out DLCType dlcType)) continue; @@ -1183,6 +1223,37 @@ internal sealed partial class MainForm : CustomForm } } + /// Fires a one-time async API query for a config-only DLC; only creates the entry if the API confirms it exists. + private static void FireConfigDlcApiQuery(MainForm form, Selection selection, string dlcId) + { + _ = Task.Run(async () => + { + try + { + string apiName = await ResolveSteamDlcName(dlcId, selection.Name, selection.Id); + if (apiName == "Unknown") + apiName = null; + if (!string.IsNullOrEmpty(apiName)) + { + if (form is null || form.Disposing || form.IsDisposed) + return; + form.Invoke(delegate + { + if (Program.Canceled) + return; + SelectionDLC dlc = SelectionDLC.GetOrCreate(DLCType.SteamHidden, selection.Id, dlcId, apiName); + dlc.Selection = selection; + dlc.Enabled = true; + }); + } + } + catch + { + // Don't create the DLC if the API query fails + } + }); + } + /// Fires background tasks per installed game to check for new DLCs from store APIs that were released since the last install or scan. Any newly discovered DLCs are added to the tree in a disabled (unchecked) state, since they are not yet configured in the unlocker config files. private static void RefreshNewDLCsForInstalledGames() { @@ -1202,9 +1273,12 @@ internal sealed partial class MainForm : CustomForm Stopwatch timer = Stopwatch.StartNew(); try { - ProgramData.Log.Info($"[DLCRefresh] Checking for new DLCs on {selection.Platform} game \"{selection.Name}\" ({selection.Id}) ...", LogDestination.Scan); + ProgramData.Log.Info($"{DlcRefreshLogPrefix}Checking for new DLCs on {selection.Platform} game \"{selection.Name}\" ({selection.Id}) ...", LogDestination.Scan); + foreach (SelectionDLC dlc in selection.DLC) + dlc.IsNew = false; HashSet currentDlcIds = []; List<(string id, string name)> newDlcList = []; + List discoveredMessages = []; if (selection.Platform == Platform.Steam) { @@ -1213,7 +1287,7 @@ internal sealed partial class MainForm : CustomForm foreach (string dlcId in await SteamStore.ParseDlcAppIds(storeAppData)) _ = currentDlcIds.Add(dlcId); - CmdAppData cmdAppData = await WithTimeout(SteamCMD.GetAppInfo(selection.Id), 16000); + CmdAppData cmdAppData = await WithTimeout(SteamCMD.GetAppInfo(selection.Id), SteamCmdTimeoutMs); if (cmdAppData is not null) foreach (string dlcId in await SteamCMD.ParseDlcAppIds(cmdAppData)) _ = currentDlcIds.Add(dlcId); @@ -1222,18 +1296,9 @@ internal sealed partial class MainForm : CustomForm { if (savedDlcIds.Contains(dlcId)) continue; - string dlcName = "Unknown"; - StoreAppData dlcStore = await SteamStore.QueryStoreAPI(dlcId, true, 0, selection.Name, selection.Id); - if (dlcStore is not null) - dlcName = dlcStore.Name; - else - { - CmdAppData dlcCmd = await WithTimeout(SteamCMD.GetAppInfo(dlcId), 16000); - if (dlcCmd?.Common?.Name is not null) - dlcName = dlcCmd.Common.Name; - } + string dlcName = await ResolveSteamDlcName(dlcId, selection.Name, selection.Id); newDlcList.Add((dlcId, dlcName)); - ProgramData.Log.Info($"[DLCRefresh] New DLC discovered for \"{selection.Name}\" ({selection.Id}): \"{dlcName}\" ({dlcId})", LogDestination.Scan); + discoveredMessages.Add($"{DlcRefreshLogPrefix}New DLC discovered for \"{selection.Name}\" ({selection.Id}): \"{dlcName}\" ({dlcId})"); } } else if (selection.Platform == Platform.Epic) @@ -1246,7 +1311,7 @@ internal sealed partial class MainForm : CustomForm if (!savedDlcIds.Contains(id)) { newDlcList.Add((id, name ?? "Unknown")); - ProgramData.Log.Info($"[DLCRefresh] New DLC discovered for \"{selection.Name}\" ({selection.Id}): \"{name ?? "Unknown"}\" ({id})", LogDestination.Scan); + discoveredMessages.Add($"{DlcRefreshLogPrefix}New DLC discovered for \"{selection.Name}\" ({selection.Id}): \"{name ?? "Unknown"}\" ({id})"); } } } @@ -1260,6 +1325,8 @@ internal sealed partial class MainForm : CustomForm { if (Program.Canceled) return; + foreach (string msg in discoveredMessages) + ProgramData.Log.Info(msg, LogDestination.Scan); foreach ((string id, string name) in newDlcList) { DLCType dlcType = selection.Platform switch @@ -1270,17 +1337,19 @@ internal sealed partial class MainForm : CustomForm }; SelectionDLC dlc = SelectionDLC.GetOrCreate(dlcType, selection.Id, id, name); dlc.Selection = selection; - dlc.Enabled = false; + dlc.Enabled = selection.InstalledUnlocker == InstalledUnlocker.SmokeAPI; + dlc.IsNew = true; } + string state = selection.InstalledUnlocker == InstalledUnlocker.SmokeAPI ? "enabled" : "disabled"; + ProgramData.Log.Info($"{DlcRefreshLogPrefix}Added {newDlcList.Count} new {state} DLC(s) to the tree for \"{selection.Name}\" ({selection.Id}) in {timer.Elapsed.TotalSeconds:F3}s", LogDestination.Scan); }); - ProgramData.Log.Info($"[DLCRefresh] Added {newDlcList.Count} new disabled DLC(s) to the tree for \"{selection.Name}\" ({selection.Id}) in {timer.Elapsed.TotalSeconds:F3}s", LogDestination.Scan); } else - ProgramData.Log.Info($"[DLCRefresh] No new DLCs found for \"{selection.Name}\" ({selection.Id}) — {currentDlcIds.Count} total DLCs known in {timer.Elapsed.TotalSeconds:F3}s", LogDestination.Scan); + ProgramData.Log.Info($"{DlcRefreshLogPrefix}No new DLCs found for \"{selection.Name}\" ({selection.Id}) — {currentDlcIds.Count} total DLCs known in {timer.Elapsed.TotalSeconds:F3}s", LogDestination.Scan); } catch (Exception e) { - ProgramData.Log.Info($"[DLCRefresh] Failed to refresh DLCs for \"{selection.Name}\" ({selection.Id}) after {timer.Elapsed.TotalSeconds:F3}s: {e.Message}", LogDestination.Scan); + ProgramData.Log.Info($"{DlcRefreshLogPrefix}Failed to refresh DLCs for \"{selection.Name}\" ({selection.Id}) after {timer.Elapsed.TotalSeconds:F3}s: {e.Message}", LogDestination.Scan); } }); refreshTasks.Add(task); @@ -1288,10 +1357,81 @@ internal sealed partial class MainForm : CustomForm Stopwatch timer = Stopwatch.StartNew(); await Task.WhenAll(refreshTasks); timer.Stop(); - ProgramData.Log.Info($"[DLCRefresh] Background DLC refresh completed for {refreshTasks.Count} installed game(s) in {timer.Elapsed.TotalSeconds:F3}s", LogDestination.Scan); + ProgramData.Log.Info($"{DlcRefreshLogPrefix}Background DLC refresh completed for {refreshTasks.Count} installed game(s) in {timer.Elapsed.TotalSeconds:F3}s", LogDestination.Scan); + + // Persist all selections with unlockers so newly discovered DLCs survive restart + PersistInstalledGames(); }); } + /// Persists all selections with a detected unlocker to installed.json, preserving existing proxy/extra-protection data so detection does not overwrite prior install state. + private static void PersistInstalledGames() + { + List installedRecords = ProgramData.ReadInstalledGames(); + foreach (Selection selection in Selection.All.Keys) + { + if (selection.InstalledUnlocker != InstalledUnlocker.None) + { + InstalledGameRecord existing = installedRecords.FirstOrDefault(r => + r.Platform == selection.Platform && r.Id == selection.Id); + ProgramData.UpsertInstalledGame(selection.ToInstalledGameRecord(existing)); + } + } + } + + /// Re-queries store/SteamCMD data for a single game and adds any newly-discovered DLCs to the tree. Does not remove existing DLCs. + private static async Task RefreshSingleGameData(Selection selection) + { + if (selection.Platform == Platform.Steam) + { + StoreAppData storeAppData = await SteamStore.QueryStoreAPI(selection.Id); + CmdAppData cmdAppData = await SteamCMD.GetAppInfo(selection.Id); + HashSet currentDlcIds = []; + if (storeAppData is not null) + foreach (string dlcId in await SteamStore.ParseDlcAppIds(storeAppData)) + _ = currentDlcIds.Add(dlcId); + if (cmdAppData is not null) + foreach (string dlcId in await SteamCMD.ParseDlcAppIds(cmdAppData)) + _ = currentDlcIds.Add(dlcId); + HashSet existingIds = selection.DLC.Select(d => d.Id).ToHashSet(); + foreach (string dlcId in currentDlcIds) + { + if (existingIds.Contains(dlcId)) + continue; + string dlcName = await ResolveSteamDlcName(dlcId, selection.Name, selection.Id); + SelectionDLC dlc = SelectionDLC.GetOrCreate(DLCType.Steam, selection.Id, dlcId, dlcName); + dlc.Selection = selection; + } + } + else if (selection.Platform == Platform.Epic) + { + List<(string id, string name, string product, string icon, string developer)> catalog = + await EpicStore.QueryCatalog(selection.Id); + HashSet existingIds = selection.DLC.Select(d => d.Id).ToHashSet(); + foreach ((string id, string name, string product, string icon, string developer) in catalog) + { + if (existingIds.Contains(id)) + continue; + SelectionDLC dlc = SelectionDLC.GetOrCreate(DLCType.Epic, selection.Id, id, name); + dlc.Product = product; + dlc.Icon = icon; + dlc.Publisher = developer; + dlc.Selection = selection; + } + } + } + + /// Re-queries the name for a single DLC and updates it in the tree. + private static async Task RefreshSingleDlcData(SelectionDLC dlc) + { + if (dlc.Type is DLCType.Steam or DLCType.SteamHidden) + { + string name = await ResolveSteamDlcName(dlc.Id, dlc.Selection?.Name, dlc.Selection?.Id); + if (name != "Unknown") + dlc.Name = name; + } + } + private void OnLoad(object sender, EventArgs _) { bool retry = true; @@ -1400,6 +1540,26 @@ internal sealed partial class MainForm : CustomForm ProgramData.WriteExtraProtectionChoices(extraProtectionChoices); + SyncInstallerConfigs(); + PersistInstalledGames(); + + OnProxyChanged(); + } + + internal void InvalidateGameList() => selectionTreeView.Invalidate(); + + internal void OnProxyChanged() + { + selectionTreeView.Invalidate(); + } + + /// + /// Detect installed unlockers and proxy DLLs, read SmokeAPI/CreamAPI configs, fire API queries for config DLCs, + /// and merge with persisted installed game records. Must run after selections are populated and unlocker detection + /// is meaningful (i.e., after on the non-scan path). + /// + private void SyncInstallerConfigs() + { // Detect installed unlockers, proxy DLLs, and read config files — grouped per-game foreach (Selection selection in Selection.All.Keys) { @@ -1437,11 +1597,24 @@ internal sealed partial class MainForm : CustomForm { foreach (string directory in selection.DllDirectories) { - HashSet enabledIds = CreamAPI.ReadConfigDlcIds(directory); - if (enabledIds is not null) + List<(string id, string name)> configDlcs = CreamAPI.ReadConfigDlcs(directory); + if (configDlcs is not null) { + HashSet configDlcIds = configDlcs.Select(e => e.id).ToHashSet(); + MainForm form = MainForm.Current; + // Sync enabled state for already-known DLCs from the CreamAPI config foreach (SelectionDLC dlc in selection.DLC) - dlc.Enabled = enabledIds.Contains(dlc.Id); + dlc.Enabled = configDlcIds.Contains(dlc.Id); + // Fire async API queries for config DLCs not already known; + // DLC entries are only created if the API confirms they exist + foreach ((string id, string name) in configDlcs) + { + if (!selection.DLC.Any(d => d.Id == id)) + { + if (form is not null && !form.Disposing && !form.IsDisposed && selection.Platform is Platform.Steam) + FireConfigDlcApiQuery(form, selection, id); + } + } break; } } @@ -1459,47 +1632,6 @@ internal sealed partial class MainForm : CustomForm if (selection.InstalledUnlocker == InstalledUnlocker.None && record.Unlocker != InstalledUnlocker.None) selection.InstalledUnlocker = record.Unlocker; } - - // Persist any selections with a detected unlocker to installed.json, preserving existing - // proxy/extrapolation data from the saved record so detection does not overwrite prior install state - foreach (Selection selection in Selection.All.Keys) - { - if (selection.InstalledUnlocker != InstalledUnlocker.None) - { - InstalledGameRecord existing = installedRecords.FirstOrDefault(r => - r.Platform == selection.Platform && r.Id == selection.Id); - ProgramData.UpsertInstalledGame(new InstalledGameRecord - { - Platform = selection.Platform, - Id = selection.Id, - Name = selection.Name, - RootDirectory = selection.RootDirectory, - Unlocker = selection.InstalledUnlocker, - UseProxy = existing?.UseProxy ?? false, - ProxyDllName = existing?.UseProxy == true ? existing.ProxyDllName : null, - UseExtraProtection = existing?.UseExtraProtection ?? false, - Dlc = selection.DLC - .GroupBy(dlc => dlc.Id) - .Select(g => g.First()) - .Select(dlc => new InstalledDlcRecord - { - DlcType = dlc.Type.ToString(), - Id = dlc.Id, - Name = dlc.Name, - Enabled = dlc.Enabled - }).ToList() - }); - } - } - - OnProxyChanged(); - } - - internal void InvalidateGameList() => selectionTreeView.Invalidate(); - - internal void OnProxyChanged() - { - selectionTreeView.Invalidate(); } internal void OnExtraProtectionChanged() diff --git a/CreamInstaller/Platforms/Steam/SteamCMD.WebAPI.cs b/CreamInstaller/Platforms/Steam/SteamCMD.WebAPI.cs index 2154b35..c4f8cc0 100644 --- a/CreamInstaller/Platforms/Steam/SteamCMD.WebAPI.cs +++ b/CreamInstaller/Platforms/Steam/SteamCMD.WebAPI.cs @@ -11,23 +11,29 @@ internal static partial class SteamCMD { private const int CooldownGame = 600; private const int CooldownDlc = 1200; + private const int WebApiConcurrency = 12; + private static readonly SemaphoreSlim WebApiGate = new(WebApiConcurrency, WebApiConcurrency); - internal static async Task QueryWebAPI(string appId, bool isDlc = false, int attempts = 0) + internal static async Task QueryWebAPI(string appId, bool isDlc = false) { - while (!Program.Canceled) + if (Program.Canceled) + return null; + + string cacheFile = ProgramData.AppInfoPath + @$"\{appId}.cmd.json"; + bool cachedExists = cacheFile.FileExists(); + + if (!cachedExists || ProgramData.CheckCooldown(appId + ".cmd", isDlc ? CooldownDlc : CooldownGame)) { - attempts++; - string cacheFile = ProgramData.AppInfoPath + @$"\{appId}.cmd.json"; - bool cachedExists = cacheFile.FileExists(); - if (!cachedExists || ProgramData.CheckCooldown(appId + ".cmd", isDlc ? CooldownDlc : CooldownGame)) + await WebApiGate.WaitAsync(); + try { (string response, bool permanentFailure) = await HttpClientManager.EnsureGet($"https://api.steamcmd.net/v1/info/{appId}"); -if (permanentFailure) + if (permanentFailure) { - ProgramData.Log.Info("[SteamAPI] SteamCMD web API query failed on attempt #" + attempts + " for " + + ProgramData.Log.Info("[SteamAPI] SteamCMD web API query failed for " + appId + (isDlc ? " (DLC)" : "") + - ": Permanent failure, aborting retries", LogDestination.Steam); + ": Permanent failure, aborting", LogDestination.Steam); return null; } if (response is not null) @@ -40,66 +46,69 @@ if (permanentFailure) if (appDetails.Data.Values.Count != 0) { CmdAppData data = appDetails.Data.Values.First(); + // If the API returned empty data (common is null), + // don't cache it fall through to the VDF/SteamCMD.exe path. + if (data.Common?.Name is null) + { + ProgramData.Log.Info( + "[SteamAPI] SteamCMD web API returned empty data for " + appId + + (isDlc ? " (DLC)" : ""), LogDestination.Steam); + return null; + } try { cacheFile.WriteFile(JsonConvert.SerializeObject(data, Formatting.Indented)); } -catch (Exception e) + catch (Exception e) { - ProgramData.Log.Info("[SteamAPI] SteamCMD web API query failed on attempt #" + attempts + - " for " + appId + (isDlc ? " (DLC)" : "") + ProgramData.Log.Info("[SteamAPI] SteamCMD web API query failed for " + + appId + (isDlc ? " (DLC)" : "") + ": Unsuccessful serialization (" + e.Message + ")", LogDestination.Steam); } return data; } else ProgramData.Log.Info( - "[SteamAPI] SteamCMD web API query failed on attempt #" + attempts + " for " + appId + + "[SteamAPI] SteamCMD web API query failed for " + appId + (isDlc ? " (DLC)" : "") + ": No data", LogDestination.Steam); } else ProgramData.Log.Info( - "[SteamAPI] SteamCMD web API query failed on attempt #" + attempts + " for " + appId + + "[SteamAPI] SteamCMD web API query failed for " + appId + (isDlc ? " (DLC)" : "") + ": Status not success (" + appDetails?.Status + ")", LogDestination.Steam); } -catch (Exception e) + catch (Exception e) { - ProgramData.Log.Info("[SteamAPI] SteamCMD web API query failed on attempt #" + attempts + " for " + + ProgramData.Log.Info("[SteamAPI] SteamCMD web API query failed for " + appId + (isDlc ? " (DLC)" : "") + ": Unsuccessful deserialization (" + e.Message + ")", LogDestination.Steam); } } else ProgramData.Log.Info( - "[SteamAPI] SteamCMD web API query failed on attempt #" + attempts + " for " + appId + + "[SteamAPI] SteamCMD web API query failed for " + appId + (isDlc ? " (DLC)" : "") + ": Response null", LogDestination.Steam); } - - if (cachedExists) - try - { - return JsonConvert.DeserializeObject(cacheFile.ReadFile()); - } - catch - { - cacheFile.DeleteFile(); - } - - if (isDlc) - break; - if (attempts > 3) + finally { - ProgramData.Log.Info("[SteamAPI] Failed to query SteamCMD web API after 3 tries: " + appId, LogDestination.Steam); - break; + WebApiGate.Release(); } - - int delayMs = Math.Min(1000 * (int)Math.Pow(2, attempts - 1), 10000); - await Task.Delay(delayMs + Random.Shared.Next(0, 1000)); } + // Fall back to cached data if available + if (cachedExists) + try + { + return JsonConvert.DeserializeObject(cacheFile.ReadFile()); + } + catch + { + cacheFile.DeleteFile(); + } + return null; } } \ No newline at end of file diff --git a/CreamInstaller/Resources/CreamAPI.cs b/CreamInstaller/Resources/CreamAPI.cs index 33237ed..18be0e2 100644 --- a/CreamInstaller/Resources/CreamAPI.cs +++ b/CreamInstaller/Resources/CreamAPI.cs @@ -28,7 +28,9 @@ internal static class CreamAPI config = directory + @"\cream_api.ini"; } - internal static HashSet ReadConfigDlcIds(string directory) + internal static HashSet ReadConfigDlcIds(string directory) => ReadConfigDlcs(directory)?.Select(e => e.id).ToHashSet(); + + internal static List<(string id, string name)> ReadConfigDlcs(string directory) { directory.GetCreamApiComponents(out _, out _, out _, out _, out string config); if (!config.FileExists()) @@ -36,7 +38,7 @@ internal static class CreamAPI try { - HashSet dlcIds = []; + List<(string id, string name)> dlcEntries = []; string[] lines = File.ReadAllLines(config, Encoding.Default); bool inDlcSection = false; foreach (string line in lines) @@ -51,14 +53,16 @@ internal static class CreamAPI break; if (inDlcSection && trimmed.Contains('=')) { - string id = trimmed.Split('=')[0].Trim(); + string[] parts = trimmed.Split('=', 2); + string id = parts[0].Trim(); + string name = parts.Length > 1 ? parts[1].Trim() : "Unknown"; if (!string.IsNullOrEmpty(id)) - dlcIds.Add(id); + dlcEntries.Add((id, name)); } } - ProgramData.Log.Info($"[CreamAPI] Read config: {config} — {dlcIds.Count} DLC", LogDestination.Unlocker); - return dlcIds; + ProgramData.Log.Info($"[CreamAPI] Read config: {config} — {dlcEntries.Count} DLC", LogDestination.Unlocker); + return dlcEntries; } catch (Exception e) { diff --git a/CreamInstaller/Selection.cs b/CreamInstaller/Selection.cs index c3b2f9f..fda42d1 100644 --- a/CreamInstaller/Selection.cs +++ b/CreamInstaller/Selection.cs @@ -28,6 +28,7 @@ internal sealed class Selection : IEquatable internal static readonly ConcurrentDictionary All = new(); + internal readonly ConcurrentDictionary DLCById = new(); internal readonly HashSet DllDirectories; internal readonly List<(string directory, BinaryType binaryType)> ExecutableDirectories; internal readonly HashSet ExtraSelections = []; @@ -86,7 +87,26 @@ internal sealed class Selection : IEquatable set => TreeNode.Checked = value; } - internal IEnumerable DLC => SelectionDLC.All.Keys.Where(dlc => Equals(dlc.Selection, this)); + internal IEnumerable DLC => DLCById.Values; + + internal InstalledGameRecord ToInstalledGameRecord(InstalledGameRecord existing = null) => new() + { + Platform = Platform, + Id = Id, + Name = Name, + RootDirectory = RootDirectory, + Unlocker = InstalledUnlocker, + UseProxy = existing?.UseProxy ?? UseProxy, + ProxyDllName = (existing?.UseProxy ?? UseProxy) ? (existing?.ProxyDllName ?? Proxy ?? DefaultProxy) : null, + UseExtraProtection = existing?.UseExtraProtection ?? UseExtraProtection, + Dlc = DLC.Select(dlc => new InstalledDlcRecord + { + DlcType = dlc.Type.ToString(), + Id = dlc.Id, + Name = dlc.Name, + Enabled = dlc.Enabled + }).ToList() + }; public bool Equals(Selection other) => other is not null && (ReferenceEquals(this, other) || @@ -102,7 +122,7 @@ internal sealed class Selection : IEquatable { _ = All.TryRemove(this, out _); TreeNode.Remove(); - foreach (SelectionDLC dlc in DLC) + foreach (SelectionDLC dlc in DLCById.Values.ToList()) dlc.Selection = null; } diff --git a/CreamInstaller/SelectionDLC.cs b/CreamInstaller/SelectionDLC.cs index 3258625..1d90dc1 100644 --- a/CreamInstaller/SelectionDLC.cs +++ b/CreamInstaller/SelectionDLC.cs @@ -19,13 +19,23 @@ internal sealed class SelectionDLC : IEquatable internal static readonly ConcurrentDictionary All = new(); internal readonly string Id; - internal readonly string Name; + private string _name; + internal string Name + { + get => _name; + set + { + _name = value; + TreeNode.Text = value; + } + } internal readonly TreeNode TreeNode; internal readonly DLCType Type; internal readonly string GameId; internal string Icon; internal string Product; internal string Publisher; + internal bool IsNew; private Selection selection; private SelectionDLC(DLCType type, string gameId, string id, string name) @@ -33,7 +43,7 @@ internal sealed class SelectionDLC : IEquatable Type = type; GameId = gameId; Id = id; - Name = name; + _name = name; TreeNode = new() { Tag = Type, Name = Id, Text = Name }; _ = All.TryAdd(this, 0); } @@ -51,6 +61,9 @@ internal sealed class SelectionDLC : IEquatable { if (ReferenceEquals(selection, value)) return; + // Remove from previous selection's per-game dictionary + if (selection is not null) + _ = selection.DLCById.TryRemove(Id, out _); selection = value; if (value is null) { @@ -60,6 +73,7 @@ internal sealed class SelectionDLC : IEquatable else { _ = All.TryAdd(this, default); + _ = value.DLCById.TryAdd(Id, this); _ = value.TreeNode.Nodes.Add(TreeNode); Enabled = Name != "Unknown" && value.Enabled; } @@ -72,17 +86,15 @@ internal sealed class SelectionDLC : IEquatable internal static SelectionDLC GetOrCreate(DLCType type, string gameId, string id, string name) { - // For Steam DLCs, Steam and SteamHidden represent the same DLC discovered - // through different code paths. Look up by (gameId, id) ignoring the Steam - // subtype to prevent duplicate entries for the same DLC. - if (type is DLCType.Steam or DLCType.SteamHidden) - { - SelectionDLC existing = All.Keys.FirstOrDefault( - dlc => dlc.GameId == gameId && dlc.Id == id - && dlc.Type is DLCType.Steam or DLCType.SteamHidden); - if (existing is not null) + // Search per-selection dictionaries first (authoritative, deduplicated by ID) + foreach (Selection s in Selection.All.Keys) + if (s.DLCById.TryGetValue(id, out SelectionDLC existing) + && existing.GameId == gameId + && (existing.Type == type + || (type is DLCType.Steam or DLCType.SteamHidden + && existing.Type is DLCType.Steam or DLCType.SteamHidden))) return existing; - } + // Fall back to the global pool for unassociated DLCs return FromId(type, gameId, id) ?? new SelectionDLC(type, gameId, id, name); }