mirror of
https://github.com/FroggMaster/CreamInstaller.git
synced 2026-07-28 14:47:04 -07:00
Merge pull request #37 from FroggMaster/dlc-background-refresh-fixes
DLC Background Refresh Fixes, API Optimization, and Game/DLC Data Query Improvements
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<string> 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<int> 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();
|
||||
}
|
||||
|
||||
if (!scan)
|
||||
{
|
||||
int loadSteps = 0;
|
||||
int loadStep = 0;
|
||||
Progress<int> 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<int> 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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Fires a one-time async API query for a config-only DLC; only creates the entry if the API confirms it exists.</summary>
|
||||
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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>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.</summary>
|
||||
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<string> currentDlcIds = [];
|
||||
List<(string id, string name)> newDlcList = [];
|
||||
List<string> 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();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Persists all selections with a detected unlocker to installed.json, preserving existing proxy/extra-protection data so detection does not overwrite prior install state.</summary>
|
||||
private static void PersistInstalledGames()
|
||||
{
|
||||
List<InstalledGameRecord> 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Re-queries store/SteamCMD data for a single game and adds any newly-discovered DLCs to the tree. Does not remove existing DLCs.</summary>
|
||||
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<string> 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<string> 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<string> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Re-queries the name for a single DLC and updates it in the tree.</summary>
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="LoadSavedInstalledGames"/> on the non-scan path).
|
||||
/// </summary>
|
||||
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<string> enabledIds = CreamAPI.ReadConfigDlcIds(directory);
|
||||
if (enabledIds is not null)
|
||||
List<(string id, string name)> configDlcs = CreamAPI.ReadConfigDlcs(directory);
|
||||
if (configDlcs is not null)
|
||||
{
|
||||
HashSet<string> 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()
|
||||
|
||||
@@ -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<CmdAppData> QueryWebAPI(string appId, bool isDlc = false, int attempts = 0)
|
||||
internal static async Task<CmdAppData> QueryWebAPI(string appId, bool isDlc = false)
|
||||
{
|
||||
while (!Program.Canceled)
|
||||
{
|
||||
attempts++;
|
||||
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))
|
||||
{
|
||||
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,44 +46,59 @@ 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);
|
||||
}
|
||||
finally
|
||||
{
|
||||
WebApiGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to cached data if available
|
||||
if (cachedExists)
|
||||
try
|
||||
{
|
||||
@@ -88,18 +109,6 @@ catch (Exception e)
|
||||
cacheFile.DeleteFile();
|
||||
}
|
||||
|
||||
if (isDlc)
|
||||
break;
|
||||
if (attempts > 3)
|
||||
{
|
||||
ProgramData.Log.Info("[SteamAPI] Failed to query SteamCMD web API after 3 tries: " + appId, LogDestination.Steam);
|
||||
break;
|
||||
}
|
||||
|
||||
int delayMs = Math.Min(1000 * (int)Math.Pow(2, attempts - 1), 10000);
|
||||
await Task.Delay(delayMs + Random.Shared.Next(0, 1000));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,9 @@ internal static class CreamAPI
|
||||
config = directory + @"\cream_api.ini";
|
||||
}
|
||||
|
||||
internal static HashSet<string> ReadConfigDlcIds(string directory)
|
||||
internal static HashSet<string> 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<string> 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)
|
||||
{
|
||||
|
||||
@@ -28,6 +28,7 @@ internal sealed class Selection : IEquatable<Selection>
|
||||
|
||||
internal static readonly ConcurrentDictionary<Selection, byte> All = new();
|
||||
|
||||
internal readonly ConcurrentDictionary<string, SelectionDLC> DLCById = new();
|
||||
internal readonly HashSet<string> DllDirectories;
|
||||
internal readonly List<(string directory, BinaryType binaryType)> ExecutableDirectories;
|
||||
internal readonly HashSet<Selection> ExtraSelections = [];
|
||||
@@ -86,7 +87,26 @@ internal sealed class Selection : IEquatable<Selection>
|
||||
set => TreeNode.Checked = value;
|
||||
}
|
||||
|
||||
internal IEnumerable<SelectionDLC> DLC => SelectionDLC.All.Keys.Where(dlc => Equals(dlc.Selection, this));
|
||||
internal IEnumerable<SelectionDLC> 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<Selection>
|
||||
{
|
||||
_ = All.TryRemove(this, out _);
|
||||
TreeNode.Remove();
|
||||
foreach (SelectionDLC dlc in DLC)
|
||||
foreach (SelectionDLC dlc in DLCById.Values.ToList())
|
||||
dlc.Selection = null;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,13 +19,23 @@ internal sealed class SelectionDLC : IEquatable<SelectionDLC>
|
||||
internal static readonly ConcurrentDictionary<SelectionDLC, byte> 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<SelectionDLC>
|
||||
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<SelectionDLC>
|
||||
{
|
||||
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<SelectionDLC>
|
||||
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<SelectionDLC>
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user