7 Commits

Author SHA1 Message Date
Frog eb075a32db Correct Generated JSON for UPlay Games Fixes #27
- Correctts the invalid comma in the generated JSON for games that utilize the UPlay unlocker.
2026-06-12 00:43:03 -07:00
Frog 30bd1035b2 Replace GoTo Statement with While Loops
- Pretty basic code adjustment, changes GOTO statements to while loops.
2026-06-11 22:31:16 -07:00
Frog 0dbd35ed0c Improve HTTP Client Manager / Improved Debug Error Messaging
- Fixed issues with how HttpClient was being created and managed, which could lead to socket leaks and connection problems. Now the app reuses a single shared HttpClient instead of creating new ones repeatedly, and properly manages the underlying connection handler.
- Improved connection handling with better timeouts, safer pooling settings, and better handling of network errors and DNS changes.
- Improved Debug Error messaging for failed SteamCMD querys
2026-06-02 01:13:11 -07:00
Frog 668463f687 Improve Potential Async Void Crash Risks
Added try catch for async handling across the application to reduce crash risk from async void methods. Added proper error handling so exceptions are caught instead of crashing the app.
2026-06-02 00:31:35 -07:00
Frog 69d29d6863 Fix: Rare Potential Crash/Null Reference
- Fixes a rare issue where the application could crash if the same window was called by multiple parts of the program at the same time. This could cause unexpected errors or crashes.
2026-06-01 23:44:09 -07:00
Frog 94bec38bd0 Recall Installed DLC Locker for Games / Labels for DLC Unlockers / Additional Extra Protection Changes
- Added method to remember the games you've installed so they don't need to be reselected.
- Added labels for to display if CreamAPI / SmokeAPI DLC Unlockers are installed
- Logic to ensure the Extra Protection checkbox displays at the appropriate time
- Added logic to read Extra Protection state from cream_api.ini when CreamAPI is detected
2026-06-01 02:46:47 -07:00
Frog 66cf72faeb Add Extra Protection Option for CreamAPI Closes #21
- Adds the ability to enable ExtraProtection for CreamAPI, this is required for games that check the integrity of the steam_api.dll

Related Work Items: #2, #21
2026-06-01 00:01:59 -07:00
17 changed files with 1130 additions and 377 deletions
+100
View File
@@ -14,8 +14,10 @@ namespace CreamInstaller.Components;
internal sealed class CustomTreeView : TreeView
{
private const string ProxyToggleString = "Proxy";
private const string ExtraProtectionToggleString = "Extra Protection";
private readonly Dictionary<Selection, Rectangle> checkBoxBounds = [];
private readonly Dictionary<Selection, Rectangle> extraProtectionCheckBoxBounds = [];
private readonly Dictionary<Selection, Rectangle> comboBoxBounds = [];
private readonly Dictionary<TreeNode, Rectangle> selectionBounds = [];
@@ -62,6 +64,7 @@ internal sealed class CustomTreeView : TreeView
private void OnInvalidated(object sender, EventArgs e)
{
checkBoxBounds.Clear();
extraProtectionCheckBoxBounds.Clear();
comboBoxBounds.Clear();
selectionBounds.Clear();
backBrush?.Dispose();
@@ -254,6 +257,93 @@ internal sealed class CustomTreeView : TreeView
graphics.FillRectangle(brush, bounds);
}
// Unlocker badge
if (selection.InstalledUnlocker != InstalledUnlocker.None)
{
string badgeText = selection.InstalledUnlocker.ToString();
size = TextRenderer.MeasureText(graphics, badgeText, font, Size.Empty, TextFormatFlags.NoPadding);
const int badgePadding = 3;
Rectangle badgeBounds = new(bounds.X + bounds.Width + 2, bounds.Y + 1, size.Width + badgePadding * 2, bounds.Height - 2);
selectionBounds = new(selectionBounds.Location, selectionBounds.Size + new Size(badgeBounds.Width + 2, 0));
// Get theme-appropriate colors for each unlocker from ThemeManager
Color badgeBack, badgeBorder;
switch (selection.InstalledUnlocker)
{
case InstalledUnlocker.SmokeAPI:
badgeBack = highlighted
? ThemeManager.SmokeAPIBadgeBackgroundHighlightColor
: ThemeManager.SmokeAPIBadgeBackgroundColor;
badgeBorder = ThemeManager.SmokeAPIBadgeBorderColor;
break;
case InstalledUnlocker.CreamAPI:
badgeBack = highlighted
? ThemeManager.CreamAPIBadgeBackgroundHighlightColor
: ThemeManager.CreamAPIBadgeBackgroundColor;
badgeBorder = ThemeManager.CreamAPIBadgeBorderColor;
break;
default:
badgeBack = highlighted
? ThemeManager.DefaultBadgeBackgroundHighlightColor
: ThemeManager.DefaultBadgeBackgroundColor;
badgeBorder = ThemeManager.DefaultBadgeBorderColor;
break;
}
using (SolidBrush badgeBrush = new(badgeBack))
graphics.FillRectangle(badgeBrush, badgeBounds);
using (Pen badgePen = new(badgeBorder))
graphics.DrawRectangle(badgePen, badgeBounds);
TextRenderer.DrawText(graphics, badgeText, font,
new Point(badgeBounds.X + badgePadding, badgeBounds.Y + 1),
Color.White, TextFormatFlags.NoPadding);
bounds = bounds with { X = badgeBounds.X, Width = badgeBounds.Width + 2 };
}
// Show Extra Protection checkbox for CreamAPI:
// - When CreamAPI is installed, OR
// - When no unlocker is installed yet AND user hasn't enabled SmokeAPI mode, OR
// - When SmokeAPI is installed BUT user has disabled SmokeAPI mode (about to replace with CreamAPI)
bool showExtraProtection = selection.InstalledUnlocker == InstalledUnlocker.CreamAPI ||
(selection.InstalledUnlocker == InstalledUnlocker.None && !Program.UseSmokeAPI) ||
(selection.InstalledUnlocker == InstalledUnlocker.SmokeAPI && !Program.UseSmokeAPI);
if (showExtraProtection)
{
CheckBoxState extraProtState = selection.UseExtraProtection
? (Enabled ? CheckBoxState.CheckedNormal : CheckBoxState.CheckedDisabled)
: (Enabled ? CheckBoxState.UncheckedNormal : CheckBoxState.UncheckedDisabled);
size = CheckBoxRenderer.GetGlyphSize(graphics, extraProtState);
bounds = bounds with { X = bounds.X + bounds.Width, Width = size.Width };
selectionBounds = new(selectionBounds.Location, selectionBounds.Size + bounds.Size with { Height = 0 });
Rectangle extraProtCheckBoxBounds = bounds;
graphics.FillRectangle(brush, bounds);
point = new(bounds.Left, bounds.Top + bounds.Height / 2 - size.Height / 2 - 1);
if (dark)
ThemeManager.DrawDarkCheckBox(graphics, point, size, selection.UseExtraProtection, Enabled);
else
CheckBoxRenderer.DrawCheckBox(graphics, point, extraProtState);
text = ExtraProtectionToggleString;
size = TextRenderer.MeasureText(graphics, text, font);
int leftEP = 1;
bounds = bounds with { X = bounds.X + bounds.Width, Width = size.Width + leftEP };
selectionBounds = new(selectionBounds.Location, selectionBounds.Size + bounds.Size with { Height = 0 });
extraProtCheckBoxBounds = new(extraProtCheckBoxBounds.Location, extraProtCheckBoxBounds.Size + bounds.Size with { Height = 0 });
graphics.FillRectangle(brush, bounds);
point = new(bounds.Location.X - 1 + leftEP, bounds.Location.Y + 1);
TextRenderer.DrawText(graphics, text, font, point,
Enabled ? ThemeManager.CustomTreeViewProxyColor : ThemeManager.CustomTreeViewDisabledProxyColor,
TextFormatFlags.Default);
extraProtectionCheckBoxBounds[selection] = RectangleToClient(extraProtCheckBoxBounds);
// Add spacing before proxy checkbox
size = new(4, 0);
bounds = bounds with { X = bounds.X + bounds.Width, Width = size.Width };
graphics.FillRectangle(brush, bounds);
}
CheckBoxState proxyState = selection.UseProxy
? (Enabled ? CheckBoxState.CheckedNormal : CheckBoxState.CheckedDisabled)
: (Enabled ? CheckBoxState.UncheckedNormal : CheckBoxState.UncheckedDisabled);
@@ -399,5 +489,15 @@ internal sealed class CustomTreeView : TreeView
selectForm?.OnProxyChanged();
break;
}
foreach (KeyValuePair<Selection, Rectangle> pair in extraProtectionCheckBoxBounds)
if (!Selection.All.ContainsKey(pair.Key))
_ = extraProtectionCheckBoxBounds.Remove(pair.Key);
else if (pair.Value.Contains(clickPoint))
{
pair.Key.UseExtraProtection = !pair.Key.UseExtraProtection;
selectForm?.OnExtraProtectionChanged();
break;
}
}
}
+9 -3
View File
@@ -9,6 +9,7 @@ namespace CreamInstaller.Forms;
internal sealed partial class DebugForm : CustomForm
{
private static DebugForm current;
private static readonly object currentLock = new();
private Form attachedForm;
@@ -22,9 +23,14 @@ internal sealed partial class DebugForm : CustomForm
{
get
{
if (current is not null && (current.Disposing || current.IsDisposed))
current = null;
return current ??= new();
lock (currentLock)
{
if (current is null || current.Disposing || current.IsDisposed)
{
current = new DebugForm();
}
return current;
}
}
}
+55 -17
View File
@@ -9,7 +9,6 @@ using CreamInstaller.Resources;
using CreamInstaller.Utility;
using static CreamInstaller.Platforms.Paradox.ParadoxLauncher;
using static CreamInstaller.Resources.Resources;
namespace CreamInstaller.Forms;
internal sealed partial class InstallForm : CustomForm
@@ -351,6 +350,41 @@ internal sealed partial class InstallForm : CustomForm
++completeOperationsCount;
}
// Persist install/uninstall results
foreach (Selection selection in Selection.AllEnabled)
{
if (uninstalling)
{
selection.InstalledUnlocker = InstalledUnlocker.None;
ProgramData.RemoveInstalledGame(selection.Platform, selection.Id);
}
else
{
InstalledUnlocker unlocker = selection.DetectInstalledUnlocker();
selection.InstalledUnlocker = unlocker;
if (unlocker != InstalledUnlocker.None)
ProgramData.UpsertInstalledGame(new InstalledGameRecord
{
Platform = selection.Platform,
Id = selection.Id,
Name = selection.Name,
RootDirectory = selection.RootDirectory,
Unlocker = unlocker,
UseProxy = selection.UseProxy,
Proxy = selection.Proxy,
UseExtraProtection = selection.UseExtraProtection,
Dlc = selection.DLC.Select(dlc => new InstalledDlcRecord
{
DlcType = dlc.Type.ToString(),
Id = dlc.Id,
Name = dlc.Name
}).ToList()
});
}
}
SelectForm.Current?.Invoke(() => SelectForm.Current?.InvalidateGameList());
Program.Cleanup();
int activeCount = activeSelections.Count;
if (activeCount > 0)
@@ -392,25 +426,29 @@ internal sealed partial class InstallForm : CustomForm
private void OnLoad(object sender, EventArgs a)
{
retry:
try
bool retry = true;
while (retry)
{
userInfoLabel.Text = "Loading . . . ";
logTextBox.Text = string.Empty;
selectionCount = 0;
foreach (Selection selection in Selection.AllEnabled)
try
{
selectionCount++;
_ = activeSelections.Add(selection);
}
userInfoLabel.Text = "Loading . . . ";
logTextBox.Text = string.Empty;
selectionCount = 0;
foreach (Selection selection in Selection.AllEnabled)
{
selectionCount++;
_ = activeSelections.Add(selection);
}
Start();
}
catch (Exception e)
{
if (e.HandleException(this))
goto retry;
Close();
Start();
retry = false;
}
catch (Exception e)
{
retry = e.HandleException(this);
if (!retry)
Close();
}
}
}
+199 -41
View File
@@ -25,6 +25,7 @@ internal sealed partial class SelectForm : CustomForm
private const string HelpButtonListPrefix = "\n • ";
private static SelectForm current;
private static readonly object currentLock = new();
private readonly ConcurrentDictionary<string, string> remainingDLCs = new();
@@ -43,9 +44,14 @@ internal sealed partial class SelectForm : CustomForm
{
get
{
if (current is not null && (current.Disposing || current.IsDisposed))
current = null;
return current ??= new();
lock (currentLock)
{
if (current is null || current.Disposing || current.IsDisposed)
{
current = new SelectForm();
}
return current;
}
}
}
@@ -241,7 +247,7 @@ internal sealed partial class SelectForm : CustomForm
string dlcName = null;
string dlcIcon = null;
bool onSteamStore = false;
StoreAppData dlcStoreAppData = await SteamStore.QueryStoreAPI(dlcAppId, true);
StoreAppData dlcStoreAppData = await SteamStore.QueryStoreAPI(dlcAppId, true, 0, name, appId);
if (dlcStoreAppData is not null)
{
dlcName = dlcStoreAppData.Name;
@@ -271,7 +277,7 @@ internal sealed partial class SelectForm : CustomForm
string fullGameIcon = null;
bool fullGameOnSteamStore = false;
StoreAppData fullGameStoreAppData =
await SteamStore.QueryStoreAPI(fullGameAppId, true);
await SteamStore.QueryStoreAPI(fullGameAppId, true, 0, null, null);
if (fullGameStoreAppData is not null)
{
fullGameName = fullGameStoreAppData.Name;
@@ -550,27 +556,29 @@ internal sealed partial class SelectForm : CustomForm
private async void OnLoad(bool forceScan = false, bool forceProvideChoices = false)
{
Program.Canceled = false;
blockedGamesCheckBox.Enabled = false;
blockProtectedHelpButton.Enabled = false;
useSmokeAPICheckBox.Enabled = false;
useSmokeAPIHelpButton.Enabled = false;
cancelButton.Enabled = true;
scanButton.Enabled = false;
noneFoundLabel.Visible = false;
allCheckBox.Enabled = false;
proxyAllCheckBox.Enabled = false;
installButton.Enabled = false;
uninstallButton.Enabled = installButton.Enabled;
selectionTreeView.Enabled = false;
saveButton.Enabled = false;
loadButton.Enabled = false;
resetButton.Enabled = false;
progressLabel.Text = "Waiting for user to select which programs/games to scan . . .";
ShowProgressBar();
await ProgramData.Setup(this);
ProgramData.ClearLog();
ProgramData.Log($"[Scan] CreamInstaller {Program.Version} — scan started at {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
try
{
Program.Canceled = false;
blockedGamesCheckBox.Enabled = false;
blockProtectedHelpButton.Enabled = false;
useSmokeAPICheckBox.Enabled = false;
useSmokeAPIHelpButton.Enabled = false;
cancelButton.Enabled = true;
scanButton.Enabled = false;
noneFoundLabel.Visible = false;
allCheckBox.Enabled = false;
proxyAllCheckBox.Enabled = false;
installButton.Enabled = false;
uninstallButton.Enabled = installButton.Enabled;
selectionTreeView.Enabled = false;
saveButton.Enabled = false;
loadButton.Enabled = false;
resetButton.Enabled = false;
progressLabel.Text = "Waiting for user to select which programs/games to scan . . .";
ShowProgressBar();
await ProgramData.Setup(this);
ProgramData.ClearLog();
ProgramData.Log($"[Scan] CreamInstaller {Program.Version} — scan started at {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
bool scan = forceScan;
if (!scan && (programsToScan is null || programsToScan.Count < 1 || forceProvideChoices))
{
@@ -689,6 +697,7 @@ internal sealed partial class SelectForm : CustomForm
}
OnLoadSelections(null, null);
await LoadSavedInstalledGames();
HideProgressBar();
selectionTreeView.Enabled = !Selection.All.IsEmpty;
allCheckBox.Enabled = selectionTreeView.Enabled;
@@ -705,6 +714,23 @@ internal sealed partial class SelectForm : CustomForm
blockProtectedHelpButton.Enabled = true;
useSmokeAPICheckBox.Enabled = true;
useSmokeAPIHelpButton.Enabled = true;
}
catch (Exception ex)
{
// Handle exceptions in async void to prevent unobserved exceptions
#if DEBUG
System.Diagnostics.Debug.WriteLine($"OnLoad exception: {ex.Message}");
#endif
// Show error and clean up
ex.HandleException(this);
HideProgressBar();
cancelButton.Enabled = false;
scanButton.Enabled = true;
blockedGamesCheckBox.Enabled = true;
blockProtectedHelpButton.Enabled = true;
useSmokeAPICheckBox.Enabled = true;
useSmokeAPIHelpButton.Enabled = true;
}
}
private void OnTreeViewNodeCheckedChanged(object sender, TreeViewEventArgs e)
@@ -974,20 +1000,97 @@ internal sealed partial class SelectForm : CustomForm
contextMenuStrip.Refresh();
});
private async Task LoadSavedInstalledGames()
{
List<InstalledGameRecord> saved = ProgramData.ReadInstalledGames();
if (saved.Count == 0)
return;
List<InstalledGameRecord> toRemove = [];
foreach (InstalledGameRecord record in saved)
{
// Already in the list from this scan — just ensure unlocker is set
Selection existing = Selection.FromId(record.Platform, record.Id);
if (existing is not null)
{
if (existing.InstalledUnlocker == InstalledUnlocker.None)
existing.InstalledUnlocker = record.Unlocker;
continue;
}
// Root directory no longer exists — mark for removal
if (!record.RootDirectory.DirectoryExists())
{
toRemove.Add(record);
continue;
}
// Reconstruct a minimal Selection from the saved record
HashSet<string> dllDirectories =
await record.RootDirectory.GetDllDirectoriesFromGameDirectory(record.Platform);
if (dllDirectories is null || dllDirectories.Count == 0)
{
toRemove.Add(record);
continue;
}
List<(string directory, BinaryType binaryType)> executableDirectories =
await record.RootDirectory.GetExecutableDirectories(true);
Selection selection = Selection.FromId(record.Platform, record.Id) ?? Selection.GetOrCreate(record.Platform, record.Id, record.Name,
record.RootDirectory, dllDirectories, executableDirectories);
selection.InstalledUnlocker = selection.DetectInstalledUnlocker();
if (selection.InstalledUnlocker == InstalledUnlocker.None)
selection.InstalledUnlocker = record.Unlocker;
selection.UseProxy = record.UseProxy;
selection.Proxy = record.Proxy;
selection.UseExtraProtection = record.UseExtraProtection;
Invoke(delegate
{
if (selection.TreeNode.TreeView is null)
_ = selectionTreeView.Nodes.Add(selection.TreeNode);
// Restore DLC children from saved record
if (record.Dlc != null && record.Dlc.Count > 0)
{
foreach (InstalledDlcRecord dlcRecord in record.Dlc)
{
if (!Enum.TryParse(dlcRecord.DlcType, out DLCType dlcType))
continue;
SelectionDLC dlc = SelectionDLC.GetOrCreate(dlcType, record.Id, dlcRecord.Id, dlcRecord.Name);
dlc.Selection = selection;
}
}
});
}
// Clean up records for games that are gone
if (toRemove.Count > 0)
{
List<InstalledGameRecord> updated = saved.Except(toRemove).ToList();
ProgramData.WriteInstalledGames(updated);
}
}
private void OnLoad(object sender, EventArgs _)
{
retry:
try
bool retry = true;
while (retry)
{
HideProgressBar();
selectionTreeView.AfterCheck += OnTreeViewNodeCheckedChanged;
OnLoad(forceProvideChoices: true);
}
catch (Exception e)
{
if (e.HandleException(this))
goto retry;
Close();
try
{
HideProgressBar();
selectionTreeView.AfterCheck += OnTreeViewNodeCheckedChanged;
OnLoad(forceProvideChoices: true);
retry = false;
}
catch (Exception e)
{
retry = e.HandleException(this);
if (!retry)
Close();
}
}
}
@@ -1065,13 +1168,18 @@ internal sealed partial class SelectForm : CustomForm
private static bool AreProxySelectionsDefault() => Selection.All.Keys.All(selection => !selection.UseProxy);
private static bool AreExtraProtectionSelectionsDefault() => Selection.All.Keys.All(selection => !selection.UseExtraProtection);
private bool CanSaveDlc() =>
installButton.Enabled && (ProgramData.ReadDlcChoices().Any() || !AreSelectionsDefault());
private static bool CanSaveProxy() =>
ProgramData.ReadProxyChoices().Any() || !AreProxySelectionsDefault();
private bool CanSaveSelections() => CanSaveDlc() || CanSaveProxy();
private static bool CanSaveExtraProtection() =>
ProgramData.ReadExtraProtectionChoices().Any() || !AreExtraProtectionSelectionsDefault();
private bool CanSaveSelections() => CanSaveDlc() || CanSaveProxy() || CanSaveExtraProtection();
private void OnSaveSelections(object sender, EventArgs e)
{
@@ -1099,6 +1207,17 @@ internal sealed partial class SelectForm : CustomForm
ProgramData.WriteProxyChoices(proxyChoices);
List<(Platform platform, string id)> extraProtectionChoices =
ProgramData.ReadExtraProtectionChoices().ToList();
foreach (Selection selection in Selection.All.Keys)
{
_ = extraProtectionChoices.RemoveAll(c => c.platform == selection.Platform && c.id == selection.Id);
if (selection.UseExtraProtection)
extraProtectionChoices.Add((selection.Platform, selection.Id));
}
ProgramData.WriteExtraProtectionChoices(extraProtectionChoices);
loadButton.Enabled = CanLoadSelections();
saveButton.Enabled = CanSaveSelections();
}
@@ -1107,7 +1226,9 @@ internal sealed partial class SelectForm : CustomForm
private static bool CanLoadProxy() => ProgramData.ReadProxyChoices().Any();
private static bool CanLoadSelections() => CanLoadDlc() || CanLoadProxy();
private static bool CanLoadExtraProtection() => ProgramData.ReadExtraProtectionChoices().Any();
private static bool CanLoadSelections() => CanLoadDlc() || CanLoadProxy() || CanLoadExtraProtection();
private void OnLoadSelections(object sender, EventArgs e)
{
@@ -1149,8 +1270,31 @@ internal sealed partial class SelectForm : CustomForm
}
ProgramData.WriteProxyChoices(proxyChoices);
List<(Platform platform, string id)> extraProtectionChoices =
ProgramData.ReadExtraProtectionChoices().ToList();
foreach (Selection selection in Selection.All.Keys)
selection.UseExtraProtection = extraProtectionChoices.Any(c =>
c.platform == selection.Platform && c.id == selection.Id);
ProgramData.WriteExtraProtectionChoices(extraProtectionChoices);
loadButton.Enabled = CanLoadSelections();
// Detect installed unlockers from disk for all selections
foreach (Selection selection in Selection.All.Keys)
selection.InstalledUnlocker = selection.DetectInstalledUnlocker();
// Merge with persisted installed game records for any saved games not yet having a detected unlocker
List<InstalledGameRecord> installedRecords = ProgramData.ReadInstalledGames();
foreach (InstalledGameRecord record in installedRecords)
{
Selection selection = Selection.FromId(record.Platform, record.Id);
if (selection is null)
continue;
if (selection.InstalledUnlocker == InstalledUnlocker.None && record.Unlocker != InstalledUnlocker.None)
selection.InstalledUnlocker = record.Unlocker;
}
OnProxyChanged();
}
@@ -1158,7 +1302,9 @@ internal sealed partial class SelectForm : CustomForm
private static bool CanResetProxy() => !AreProxySelectionsDefault();
private bool CanResetSelections() => CanResetDlc() || CanResetProxy();
private static bool CanResetExtraProtection() => !AreExtraProtectionSelectionsDefault();
private bool CanResetSelections() => CanResetDlc() || CanResetProxy() || CanResetExtraProtection();
private void OnResetSelections(object sender, EventArgs e)
{
@@ -1172,11 +1318,14 @@ internal sealed partial class SelectForm : CustomForm
{
selection.UseProxy = false;
selection.Proxy = null;
selection.UseExtraProtection = false;
}
OnProxyChanged();
}
internal void InvalidateGameList() => selectionTreeView.Invalidate();
internal void OnProxyChanged()
{
selectionTreeView.Invalidate();
@@ -1187,6 +1336,13 @@ internal sealed partial class SelectForm : CustomForm
proxyAllCheckBox.CheckedChanged += OnProxyAllCheckBoxChanged;
}
internal void OnExtraProtectionChanged()
{
selectionTreeView.Invalidate();
saveButton.Enabled = CanSaveSelections();
resetButton.Enabled = CanResetSelections();
}
private void OnBlockProtectedGamesCheckBoxChanged(object sender, EventArgs e)
{
Program.BlockProtectedGames = blockedGamesCheckBox.Checked;
@@ -1221,7 +1377,9 @@ internal sealed partial class SelectForm : CustomForm
private void OnUseSmokeAPICheckBoxChanged(object sender, EventArgs e)
{
Program.UseSmokeAPI = useSmokeAPICheckBox.Checked;
OnLoad(forceProvideChoices: false);
selectionTreeView.Invalidate();
saveButton.Enabled = CanSaveSelections();
resetButton.Enabled = CanResetSelections();
}
private void OnUseSmokeAPIHelpButtonClicked(object sender, EventArgs e)
+2 -3
View File
@@ -81,9 +81,8 @@ internal sealed partial class TestGameForm : CustomForm
string name = await Task.Run(async () =>
{
// Use a dedicated client with a neutral UA so Steam's store API doesn't reject the request.
using System.Net.Http.HttpClient client = new();
client.DefaultRequestHeaders.UserAgent.ParseAdd($"{Program.Name}/{Program.Version}");
// Use an isolated client with neutral UA so Steam's store API doesn't reject the request.
using System.Net.Http.HttpClient client = HttpClientManager.CreateIsolatedClient();
string url = $"https://store.steampowered.com/api/appdetails?appids={appId}&filters=basic";
try
{
+194 -163
View File
@@ -45,77 +45,95 @@ internal sealed partial class UpdateForm : CustomForm
private async void OnLoad()
{
progressBar.Visible = false;
ignoreButton.Visible = true;
updateButton.Text = "Update";
updateButton.Click -= OnUpdateCancel;
progressLabel.Text = "Checking for updates . . .";
changelogTreeView.Visible = false;
changelogTreeView.Location = progressLabel.Location with
try
{
Y = progressLabel.Location.Y + progressLabel.Size.Height + 13
};
Refresh();
#if !DEBUG
Version currentVersion = new(Program.Version);
#endif
List<ProgramRelease> releases = null;
string response =
await HttpClientManager.EnsureGet(
$"https://api.github.com/repos/{Program.RepositoryOwner}/{Program.RepositoryName}/releases");
if (response is not null)
releases = JsonConvert.DeserializeObject<List<ProgramRelease>>(response)
?.Where(release => !release.Draft && !release.Prerelease && release.Asset is not null).ToList();
latestRelease = releases?.FirstOrDefault();
#if DEBUG
if (latestRelease?.Version is not { } latestVersion)
#else
if (latestRelease?.Version is not { } latestVersion || latestVersion <= currentVersion)
#endif
StartProgram();
else
{
progressLabel.Text = $"An update is available: v{latestVersion}";
ignoreButton.Enabled = true;
updateButton.Enabled = true;
updateButton.Click += OnUpdate;
changelogTreeView.Visible = true;
foreach (ProgramRelease release in releases)
progressBar.Visible = false;
ignoreButton.Visible = true;
updateButton.Text = "Update";
updateButton.Click -= OnUpdateCancel;
progressLabel.Text = "Checking for updates . . .";
changelogTreeView.Visible = false;
changelogTreeView.Location = progressLabel.Location with
{
Y = progressLabel.Location.Y + progressLabel.Size.Height + 13
};
Refresh();
#if !DEBUG
if (release.Version <= currentVersion)
continue;
Version currentVersion = new(Program.Version);
#endif
TreeNode root = new(release.Name) { Name = release.Name };
changelogTreeView.Nodes.Add(root);
if (changelogTreeView.Nodes.Count > 0)
changelogTreeView.Nodes[0].EnsureVisible();
foreach (string change in release.Changes)
Invoke(delegate
{
TreeNode changeNode = new() { Text = change };
root.Nodes.Add(changeNode);
root.Expand();
if (changelogTreeView.Nodes.Count > 0)
changelogTreeView.Nodes[0].EnsureVisible();
});
List<ProgramRelease> releases = null;
string response =
await HttpClientManager.EnsureGet(
$"https://api.github.com/repos/{Program.RepositoryOwner}/{Program.RepositoryName}/releases");
if (response is not null)
releases = JsonConvert.DeserializeObject<List<ProgramRelease>>(response)
?.Where(release => !release.Draft && !release.Prerelease && release.Asset is not null).ToList();
latestRelease = releases?.FirstOrDefault();
#if DEBUG
if (latestRelease?.Version is not { } latestVersion)
#else
if (latestRelease?.Version is not { } latestVersion || latestVersion <= currentVersion)
#endif
StartProgram();
else
{
progressLabel.Text = $"An update is available: v{latestVersion}";
ignoreButton.Enabled = true;
updateButton.Enabled = true;
updateButton.Click += OnUpdate;
changelogTreeView.Visible = true;
foreach (ProgramRelease release in releases)
{
#if !DEBUG
if (release.Version <= currentVersion)
continue;
#endif
TreeNode root = new(release.Name) { Name = release.Name };
changelogTreeView.Nodes.Add(root);
if (changelogTreeView.Nodes.Count > 0)
changelogTreeView.Nodes[0].EnsureVisible();
foreach (string change in release.Changes)
Invoke(delegate
{
TreeNode changeNode = new() { Text = change };
root.Nodes.Add(changeNode);
root.Expand();
if (changelogTreeView.Nodes.Count > 0)
changelogTreeView.Nodes[0].EnsureVisible();
});
}
}
}
catch (Exception ex)
{
// Handle exceptions in async void to prevent unobserved exceptions
#if DEBUG
System.Diagnostics.Debug.WriteLine($"OnLoad exception: {ex.Message}");
ex.HandleFatalException();
#else
// In release, try to continue gracefully
StartProgram();
#endif
}
}
private void OnLoad(object sender, EventArgs _)
{
retry:
try
bool retry = true;
while (retry)
{
UpdaterPath.DeleteFile();
OnLoad();
}
catch (Exception e)
{
if (e.HandleException(this))
goto retry;
Close();
try
{
UpdaterPath.DeleteFile();
OnLoad();
retry = false;
}
catch (Exception e)
{
retry = e.HandleException(this);
if (!retry)
Close();
}
}
}
@@ -123,122 +141,135 @@ internal sealed partial class UpdateForm : CustomForm
private async void OnUpdate(object sender, EventArgs e)
{
progressBar.Value = 0;
progressBar.Visible = true;
ignoreButton.Visible = false;
updateButton.Text = "Cancel";
updateButton.Click -= OnUpdate;
updateButton.Click += OnUpdateCancel;
changelogTreeView.Location =
progressBar.Location with { Y = progressBar.Location.Y + progressBar.Size.Height + 6 };
Refresh();
Progress<int> progress = new();
IProgress<int> iProgress = progress;
progress.ProgressChanged += delegate(object _, int _progress)
{
progressLabel.Text = $"Updating . . . {_progress}%";
progressBar.Value = _progress;
};
progressLabel.Text = "Updating . . . ";
cancellation = new();
bool success = true;
PackagePath.DeleteFile(true);
await using FileStream update = PackagePath.CreateFile(true);
bool retry = true;
try
{
if (cancellation is null || Program.Canceled)
throw new TaskCanceledException();
using HttpResponseMessage response = await HttpClientManager.HttpClient.GetAsync(
latestRelease.Asset.BrowserDownloadUrl,
HttpCompletionOption.ResponseHeadersRead, cancellation.Token);
_ = response.EnsureSuccessStatusCode();
if (cancellation is null || Program.Canceled)
throw new TaskCanceledException();
await using Stream download = await response.Content.ReadAsStreamAsync(cancellation.Token);
double bytes = latestRelease.Asset.Size;
byte[] buffer = new byte[16384];
long bytesRead = 0;
int newBytes;
while (cancellation is not null && !Program.Canceled
&& (newBytes = await download.ReadAsync(buffer.AsMemory(0, buffer.Length),
cancellation.Token)) != 0)
progressBar.Value = 0;
progressBar.Visible = true;
ignoreButton.Visible = false;
updateButton.Text = "Cancel";
updateButton.Click -= OnUpdate;
updateButton.Click += OnUpdateCancel;
changelogTreeView.Location =
progressBar.Location with { Y = progressBar.Location.Y + progressBar.Size.Height + 6 };
Refresh();
Progress<int> progress = new();
IProgress<int> iProgress = progress;
progress.ProgressChanged += delegate(object _, int _progress)
{
progressLabel.Text = $"Updating . . . {_progress}%";
progressBar.Value = _progress;
};
progressLabel.Text = "Updating . . . ";
cancellation = new();
bool success = true;
PackagePath.DeleteFile(true);
await using FileStream update = PackagePath.CreateFile(true);
bool retry = true;
try
{
if (cancellation is null || Program.Canceled)
throw new TaskCanceledException();
await update.WriteAsync(buffer.AsMemory(0, newBytes), cancellation.Token);
bytesRead += newBytes;
int report = (int)(bytesRead / bytes * 100);
if (report <= progressBar.Value)
continue;
iProgress.Report(report);
using HttpResponseMessage response = await HttpClientManager.HttpClient.GetAsync(
latestRelease.Asset.BrowserDownloadUrl,
HttpCompletionOption.ResponseHeadersRead, cancellation.Token);
_ = response.EnsureSuccessStatusCode();
if (cancellation is null || Program.Canceled)
throw new TaskCanceledException();
await using Stream download = await response.Content.ReadAsStreamAsync(cancellation.Token);
double bytes = latestRelease.Asset.Size;
byte[] buffer = new byte[16384];
long bytesRead = 0;
int newBytes;
while (cancellation is not null && !Program.Canceled
&& (newBytes = await download.ReadAsync(buffer.AsMemory(0, buffer.Length),
cancellation.Token)) != 0)
{
if (cancellation is null || Program.Canceled)
throw new TaskCanceledException();
await update.WriteAsync(buffer.AsMemory(0, newBytes), cancellation.Token);
bytesRead += newBytes;
int report = (int)(bytesRead / bytes * 100);
if (report <= progressBar.Value)
continue;
iProgress.Report(report);
}
iProgress.Report((int)(bytesRead / bytes * 100));
if (cancellation is null || Program.Canceled)
throw new TaskCanceledException();
}
catch (TaskCanceledException)
{
success = false;
}
catch (Exception ex)
{
retry = ex.HandleException(this, Program.Name + " encountered an exception while updating");
success = false;
}
iProgress.Report((int)(bytesRead / bytes * 100));
if (cancellation is null || Program.Canceled)
throw new TaskCanceledException();
}
catch (TaskCanceledException)
{
success = false;
cancellation?.Dispose();
cancellation = null;
await update.DisposeAsync();
bool canContinue = success && !Program.Canceled;
if (canContinue)
updateButton.Enabled = false;
ExecutablePath.DeleteFile(canContinue);
if (canContinue)
await Task.Run(() => PackagePath.ExtractZip(ProgramData.DirectoryPath, true, this));
PackagePath.DeleteFile(canContinue);
if (canContinue)
{
string path = Program.CurrentProcessFilePath;
string directory = Path.GetDirectoryName(path);
string file = Path.GetFileName(path);
StringBuilder commands = new();
_ = commands.AppendLine(CultureInfo.InvariantCulture, $"chcp 65001");
_ = commands.AppendLine(CultureInfo.InvariantCulture, $":LOOP");
_ = commands.AppendLine(CultureInfo.InvariantCulture, $"TASKKILL /F /T /PID {Program.CurrentProcessId}");
_ = commands.AppendLine(CultureInfo.InvariantCulture, $"TASKLIST | FIND \" {Program.CurrentProcessId} \"");
_ = commands.AppendLine(CultureInfo.InvariantCulture, $"IF NOT ERRORLEVEL 1 (");
_ = commands.AppendLine(CultureInfo.InvariantCulture, $" TIMEOUT /T 1");
_ = commands.AppendLine(CultureInfo.InvariantCulture, $" GOTO LOOP");
_ = commands.AppendLine(CultureInfo.InvariantCulture, $")");
_ = commands.AppendLine(CultureInfo.InvariantCulture, $"MOVE /Y \"{ExecutablePath}\" \"{path}\"");
_ = commands.AppendLine(CultureInfo.InvariantCulture, $"START \"\" /D \"{directory}\" \"{file}\"");
#if DEBUG
_ = commands.AppendLine(CultureInfo.InvariantCulture, $"PAUSE");
#endif
_ = commands.AppendLine(CultureInfo.InvariantCulture, $"EXIT");
UpdaterPath.WriteFile(commands.ToString(), true, this, Encoding.Default);
Process process = new();
ProcessStartInfo startInfo = new()
{
WorkingDirectory = ProgramData.DirectoryPath, FileName = "cmd.exe",
Arguments = $"/C START \"UPDATER\" /B {Path.GetFileName(UpdaterPath)}",
#if DEBUG
CreateNoWindow = false
#else
CreateNoWindow = true
#endif
};
process.StartInfo = startInfo;
_ = process.Start();
return;
}
if (!retry)
StartProgram();
else
OnLoad();
}
catch (Exception ex)
{
retry = ex.HandleException(this, Program.Name + " encountered an exception while updating");
success = false;
}
cancellation?.Dispose();
cancellation = null;
await update.DisposeAsync();
bool canContinue = success && !Program.Canceled;
if (canContinue)
updateButton.Enabled = false;
ExecutablePath.DeleteFile(canContinue);
if (canContinue)
await Task.Run(() => PackagePath.ExtractZip(ProgramData.DirectoryPath, true, this));
PackagePath.DeleteFile(canContinue);
if (canContinue)
{
string path = Program.CurrentProcessFilePath;
string directory = Path.GetDirectoryName(path);
string file = Path.GetFileName(path);
StringBuilder commands = new();
_ = commands.AppendLine(CultureInfo.InvariantCulture, $"chcp 65001");
_ = commands.AppendLine(CultureInfo.InvariantCulture, $":LOOP");
_ = commands.AppendLine(CultureInfo.InvariantCulture, $"TASKKILL /F /T /PID {Program.CurrentProcessId}");
_ = commands.AppendLine(CultureInfo.InvariantCulture, $"TASKLIST | FIND \" {Program.CurrentProcessId} \"");
_ = commands.AppendLine(CultureInfo.InvariantCulture, $"IF NOT ERRORLEVEL 1 (");
_ = commands.AppendLine(CultureInfo.InvariantCulture, $" TIMEOUT /T 1");
_ = commands.AppendLine(CultureInfo.InvariantCulture, $" GOTO LOOP");
_ = commands.AppendLine(CultureInfo.InvariantCulture, $")");
_ = commands.AppendLine(CultureInfo.InvariantCulture, $"MOVE /Y \"{ExecutablePath}\" \"{path}\"");
_ = commands.AppendLine(CultureInfo.InvariantCulture, $"START \"\" /D \"{directory}\" \"{file}\"");
// Handle exceptions in async void event handler to prevent unobserved exceptions
#if DEBUG
_ = commands.AppendLine(CultureInfo.InvariantCulture, $"PAUSE");
System.Diagnostics.Debug.WriteLine($"OnUpdate exception: {ex.Message}");
#endif
_ = commands.AppendLine(CultureInfo.InvariantCulture, $"EXIT");
UpdaterPath.WriteFile(commands.ToString(), true, this, Encoding.Default);
Process process = new();
ProcessStartInfo startInfo = new()
{
WorkingDirectory = ProgramData.DirectoryPath, FileName = "cmd.exe",
Arguments = $"/C START \"UPDATER\" /B {Path.GetFileName(UpdaterPath)}",
#if DEBUG
CreateNoWindow = false
#else
CreateNoWindow = true
#endif
};
process.StartInfo = startInfo;
_ = process.Start();
return;
}
if (!retry)
// Show error to user
ex.HandleException(this, Program.Name + " encountered an unexpected error during update");
StartProgram();
else
OnLoad();
}
}
private void OnUpdateCancel(object sender, EventArgs e)
+95 -81
View File
@@ -46,82 +46,84 @@ internal static partial class SteamCMD
private static async Task<string> Run(string appId)
=> await Task.Run(() =>
{
wait_for_lock:
if (Program.Canceled)
return "";
for (int i = 0; i < Locks.Length; i++)
while (true)
{
if (Program.Canceled)
return "";
if (Interlocked.CompareExchange(ref Locks[i], 1, 0) != 0)
continue;
if (appId != null)
{
_ = AttemptCount.TryGetValue(appId, out int count);
AttemptCount[appId] = ++count;
}
if (Program.Canceled)
return "";
ProcessStartInfo processStartInfo = new()
{
FileName = FilePath, RedirectStandardOutput = true, RedirectStandardInput = true,
RedirectStandardError = true,
UseShellExecute = false, Arguments = appId is null ? "+quit" : GetArguments(appId),
CreateNoWindow = true,
StandardInputEncoding = Encoding.UTF8, StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
Process process = Process.Start(processStartInfo);
StringBuilder output = new();
StringBuilder appInfo = new();
bool appInfoStarted = false;
DateTime lastOutput = DateTime.UtcNow;
while (process != null)
for (int i = 0; i < Locks.Length; i++)
{
if (Program.Canceled)
return "";
if (Interlocked.CompareExchange(ref Locks[i], 1, 0) != 0)
continue;
if (appId != null)
{
_ = AttemptCount.TryGetValue(appId, out int count);
AttemptCount[appId] = ++count;
}
if (Program.Canceled)
return "";
ProcessStartInfo processStartInfo = new()
{
FileName = FilePath, RedirectStandardOutput = true, RedirectStandardInput = true,
RedirectStandardError = true,
UseShellExecute = false, Arguments = appId is null ? "+quit" : GetArguments(appId),
CreateNoWindow = true,
StandardInputEncoding = Encoding.UTF8, StandardOutputEncoding = Encoding.UTF8,
StandardErrorEncoding = Encoding.UTF8
};
Process process = Process.Start(processStartInfo);
StringBuilder output = new();
StringBuilder appInfo = new();
bool appInfoStarted = false;
DateTime lastOutput = DateTime.UtcNow;
while (process != null)
{
if (Program.Canceled)
{
process.Kill(true);
process.Close();
break;
}
int c = process.StandardOutput.Read();
if (c != -1)
{
lastOutput = DateTime.UtcNow;
char ch = (char)c;
if (ch == '{')
appInfoStarted = true;
_ = appInfoStarted ? appInfo.Append(ch) : output.Append(ch);
}
DateTime now = DateTime.UtcNow;
TimeSpan timeDiff = now - lastOutput;
if (!(timeDiff.TotalSeconds > 0.1))
continue;
process.Kill(true);
process.Close();
break;
if (appId != null &&
output.ToString().Contains($"No app info for AppID {appId} found, requesting..."))
{
AttemptCount[appId]++;
processStartInfo.Arguments = GetArguments(appId);
process = Process.Start(processStartInfo);
appInfoStarted = false;
_ = output.Clear();
_ = appInfo.Clear();
}
else
break;
}
int c = process.StandardOutput.Read();
if (c != -1)
{
lastOutput = DateTime.UtcNow;
char ch = (char)c;
if (ch == '{')
appInfoStarted = true;
_ = appInfoStarted ? appInfo.Append(ch) : output.Append(ch);
}
DateTime now = DateTime.UtcNow;
TimeSpan timeDiff = now - lastOutput;
if (!(timeDiff.TotalSeconds > 0.1))
continue;
process.Kill(true);
process.Close();
if (appId != null &&
output.ToString().Contains($"No app info for AppID {appId} found, requesting..."))
{
AttemptCount[appId]++;
processStartInfo.Arguments = GetArguments(appId);
process = Process.Start(processStartInfo);
appInfoStarted = false;
_ = output.Clear();
_ = appInfo.Clear();
}
else
break;
_ = Interlocked.Decrement(ref Locks[i]);
return appInfo.ToString();
}
_ = Interlocked.Decrement(ref Locks[i]);
return appInfo.ToString();
Thread.Sleep(200);
}
Thread.Sleep(200);
goto wait_for_lock;
});
internal static async Task<bool> Setup(IProgress<int> progress)
@@ -129,27 +131,39 @@ internal static partial class SteamCMD
await Cleanup();
if (!FilePath.FileExists())
{
retryDownload:
HttpClient httpClient = HttpClientManager.HttpClient;
if (httpClient is null)
return false;
while (!Program.Canceled)
try
{
byte[] file =
await httpClient.GetByteArrayAsync(
new Uri("https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip"));
_ = file.WriteResource(ArchivePath);
ArchivePath.ExtractZip(DirectoryPath);
ArchivePath.DeleteFile();
break;
}
catch (Exception e)
{
if (e.HandleException(caption: Program.Name + " failed to download SteamCMD"))
goto retryDownload;
bool retryDownload = true;
while (retryDownload)
{
HttpClient httpClient = HttpClientManager.HttpClient;
if (httpClient is null)
return false;
bool downloadSuccess = false;
while (!Program.Canceled && !downloadSuccess)
{
try
{
byte[] file =
await httpClient.GetByteArrayAsync(
new Uri("https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip"));
_ = file.WriteResource(ArchivePath);
ArchivePath.ExtractZip(DirectoryPath);
ArchivePath.DeleteFile();
downloadSuccess = true;
retryDownload = false;
}
catch (Exception e)
{
retryDownload = e.HandleException(caption: Program.Name + " failed to download SteamCMD");
if (!retryDownload)
return false;
break;
}
}
if (downloadSuccess)
break;
}
}
if (DllPath.FileExists())
+44 -24
View File
@@ -18,6 +18,25 @@ internal static class SteamStore
private const int CooldownGame = 600;
private const int CooldownDlc = 1200;
#if DEBUG
private static string FormatErrorLog(int attempts, string appId, string gameName, bool isDlc, string reason,
string parentGameName = null, string parentGameAppId = null)
{
if (isDlc && parentGameName != null && parentGameAppId != null)
{
return $"[SteamQuery][Attempt {attempts}][FAILED]\n" +
$"BaseGame: \"{parentGameName}\" ({parentGameAppId})\n" +
$"DLC: \"{gameName}\" ({appId})\n" +
$"Type: DLC\n" +
$"Reason: {reason}\n" +
"-------";
}
string type = isDlc ? "DLC" : "Game";
return $"[SteamQuery][Attempt {attempts}][FAILED] AppId: {appId} | Name: \"{gameName}\" | Type: {type} | Reason: {reason}";
}
#endif
internal static async Task<HashSet<string>> ParseDlcAppIds(StoreAppData storeAppData)
=> await Task.Run(() =>
{
@@ -31,8 +50,9 @@ internal static class SteamStore
return dlcIds;
});
internal static async Task<StoreAppData> QueryStoreAPI(string appId, bool isDlc = false, int attempts = 0)
internal static async Task<StoreAppData> QueryStoreAPI(string appId, bool isDlc = false, int attempts = 0, string parentGameName = null, string parentGameAppId = null)
{
string gameName = "Unknown";
while (!Program.Canceled)
{
attempts++;
@@ -55,13 +75,14 @@ internal static class SteamStore
if (storeAppDetails is not null)
{
StoreAppData data = storeAppDetails.Data;
if (data?.Name is not null)
gameName = data.Name;
if (!storeAppDetails.Success)
{
#if DEBUG
DebugForm.Current.Log(
"Steam store query failed on attempt #" + attempts + " for " + appId +
(isDlc ? " (DLC)" : "")
+ ": Query unsuccessful (" + app.Value.ToString(Formatting.None) + ")",
FormatErrorLog(attempts, appId, gameName, isDlc, "Query unsuccessful", parentGameName, parentGameAppId),
LogTextBox.Warning);
#endif
if (data is null)
@@ -78,9 +99,8 @@ internal static class SteamStore
#if DEBUG
(Exception e)
{
DebugForm.Current.Log("Steam store query failed on attempt #" + attempts +
" for " + appId + (isDlc ? " (DLC)" : "")
+ ": Unsuccessful serialization (" + e.Message + ")");
DebugForm.Current.Log(
FormatErrorLog(attempts, appId, gameName, isDlc, $"Unsuccessful serialization ({e.Message})", parentGameName, parentGameAppId));
}
#else
{
@@ -90,27 +110,24 @@ internal static class SteamStore
return data;
}
#if DEBUG
DebugForm.Current.Log("Steam store query failed on attempt #" + attempts + " for " +
appId + (isDlc ? " (DLC)" : "")
+ ": Response data null (" +
app.Value.ToString(Formatting.None) + ")");
DebugForm.Current.Log(
FormatErrorLog(attempts, appId, gameName, isDlc, "Response data null", parentGameName, parentGameAppId));
#endif
}
#if DEBUG
else
DebugForm.Current.Log("Steam store query failed on attempt #" + attempts + " for " +
appId + (isDlc ? " (DLC)" : "")
+ ": Response details null (" +
app.Value.ToString(Formatting.None) + ")");
{
DebugForm.Current.Log(
FormatErrorLog(attempts, appId, gameName, isDlc, "Response details null", parentGameName, parentGameAppId));
}
#endif
}
catch
#if DEBUG
(Exception e)
{
DebugForm.Current.Log("Steam store query failed on attempt #" + attempts + " for " +
appId + (isDlc ? " (DLC)" : "")
+ ": Unsuccessful deserialization (" + e.Message + ")");
DebugForm.Current.Log(
FormatErrorLog(attempts, appId, gameName, isDlc, $"Unsuccessful deserialization ({e.Message})", parentGameName, parentGameAppId));
}
#else
{
@@ -119,17 +136,19 @@ internal static class SteamStore
#endif
#if DEBUG
else
DebugForm.Current.Log("Steam store query failed on attempt #" + attempts + " for " + appId +
(isDlc ? " (DLC)" : "")
+ ": Response deserialization null");
{
DebugForm.Current.Log(
FormatErrorLog(attempts, appId, gameName, isDlc, "Response deserialization null", parentGameName, parentGameAppId));
}
#endif
}
#if DEBUG
else
{
DebugForm.Current.Log(
"Steam store query failed on attempt #" + attempts + " for " + appId + (isDlc ? " (DLC)" : "") +
": Response null",
FormatErrorLog(attempts, appId, gameName, isDlc, "Null or empty response", parentGameName, parentGameAppId),
LogTextBox.Warning);
}
#endif
}
@@ -148,7 +167,8 @@ internal static class SteamStore
if (attempts > 10)
{
#if DEBUG
DebugForm.Current.Log("Failed to query Steam store after 10 tries: " + appId);
DebugForm.Current.Log(
FormatErrorLog(attempts, appId, gameName, isDlc, "Maximum retry attempts exceeded (10)", parentGameName, parentGameAppId));
#endif
break;
}
+83 -19
View File
@@ -2,6 +2,7 @@ using System;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using CreamInstaller.Forms;
using CreamInstaller.Platforms.Steam;
@@ -65,24 +66,30 @@ internal static class Program
Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException);
AppDomain.CurrentDomain.UnhandledException +=
(_, e) => (e.ExceptionObject as Exception)?.HandleFatalException();
retry:
try
bool retry = true;
while (retry)
{
HttpClientManager.Setup();
using UpdateForm form = new();
try
{
HttpClientManager.Setup();
using UpdateForm form = new();
#if DEBUG
DebugForm.Current.Attach(form);
DebugForm.Current.Attach(form);
#endif
// Apply initial theme (dark by default)
Utility.ThemeManager.Apply(form);
Application.Run(form);
}
catch (Exception e)
{
if (e.HandleException())
goto retry;
Application.Exit();
return;
// Apply initial theme (dark by default)
Utility.ThemeManager.Apply(form);
Application.Run(form);
retry = false;
}
catch (Exception e)
{
retry = e.HandleException();
if (!retry)
{
Application.Exit();
return;
}
}
}
}
@@ -91,15 +98,72 @@ internal static class Program
internal static bool Canceled;
internal static async void Cleanup(bool cancel = true)
/// <summary>
/// Initiates application cleanup asynchronously. Use this when you can await the result.
/// </summary>
/// <param name="cancel">Whether to set the Canceled flag</param>
/// <returns>Task that completes when cleanup is finished</returns>
internal static async Task CleanupAsync(bool cancel = true)
{
Canceled = cancel;
if (cancel)
Canceled = true;
await SteamCMD.Cleanup();
}
/// <summary>
/// Synchronous cleanup wrapper for event handlers and other synchronous contexts.
/// Initiates cleanup without blocking but does not wait for completion.
/// </summary>
/// <param name="cancel">Whether to set the Canceled flag</param>
internal static void Cleanup(bool cancel = true)
{
if (cancel)
Canceled = true;
// Fire and forget - don't block synchronous callers
// Any exceptions will be logged but won't crash the app
_ = Task.Run(async () =>
{
try
{
await SteamCMD.Cleanup();
}
catch (Exception ex)
{
#if DEBUG
System.Diagnostics.Debug.WriteLine($"Cleanup failed: {ex.Message}");
#endif
// Swallow exceptions during fire-and-forget cleanup
}
});
}
private static void OnApplicationExit(object s, EventArgs e)
{
Cleanup();
HttpClientManager.Dispose();
Canceled = true;
// For application exit, we should try to wait briefly for cleanup
try
{
Task cleanupTask = SteamCMD.Cleanup();
// Wait up to 5 seconds for graceful cleanup
if (!cleanupTask.Wait(TimeSpan.FromSeconds(5)))
{
#if DEBUG
System.Diagnostics.Debug.WriteLine("Cleanup timed out during application exit");
#endif
}
}
catch (Exception ex)
{
#if DEBUG
System.Diagnostics.Debug.WriteLine($"Cleanup exception during exit: {ex.Message}");
#endif
// Ignore exceptions during shutdown
}
finally
{
HttpClientManager.Dispose();
}
}
}
+4 -3
View File
@@ -41,14 +41,15 @@ internal static class CreamAPI
config.CreateFile(true, installForm)?.Close();
StreamWriter writer = new(config, true, Encoding.Default);
WriteConfig(writer, selection.Name, !int.TryParse(selection.Id, out _) ? "0" : selection.Id,
new(dlc.ToDictionary(_dlc => _dlc.Id, _dlc => _dlc.Name), PlatformIdComparer.String), installForm);
new(dlc.ToDictionary(_dlc => _dlc.Id, _dlc => _dlc.Name), PlatformIdComparer.String),
selection.UseExtraProtection, installForm);
writer.Flush();
writer.Close();
return;
}
private static void WriteConfig(StreamWriter writer, string name, string appId,
SortedList<string, string> dlc, InstallForm installForm = null)
SortedList<string, string> dlc, bool extraProtection = false, InstallForm installForm = null)
{
writer.WriteLine($"; {name}");
writer.WriteLine("[steam]");
@@ -58,7 +59,7 @@ internal static class CreamAPI
writer.WriteLine("unlockall = false");
writer.WriteLine("orgapi = steam_api_o.dll");
writer.WriteLine("orgapi64 = steam_api64_o.dll");
writer.WriteLine("extraprotection = false"); // we may want to set this on by default?
writer.WriteLine($"extraprotection = {(extraProtection ? "true" : "false")}");
writer.WriteLine("forceoffline = false");
writer.WriteLine();
writer.WriteLine("[steam_misc]"); // this line seems to be required in v5.3.0.0, or the config won't be read
+2 -2
View File
@@ -68,10 +68,10 @@ internal static class UplayR1
false);
}
writer.WriteLine(" ],");
writer.WriteLine(" ]");
}
else
writer.WriteLine(" \"blacklist\": [],");
writer.WriteLine(" \"blacklist\": []");
writer.WriteLine("}");
}
+2 -2
View File
@@ -72,10 +72,10 @@ internal static class UplayR2
false);
}
writer.WriteLine(" ],");
writer.WriteLine(" ]");
}
else
writer.WriteLine(" \"blacklist\": [],");
writer.WriteLine(" \"blacklist\": []");
writer.WriteLine("}");
}
+100 -1
View File
@@ -1,13 +1,16 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Windows.Forms;
using CreamInstaller.Forms;
using CreamInstaller.Platforms.Epic;
using CreamInstaller.Platforms.Steam;
using CreamInstaller.Platforms.Ubisoft;
using CreamInstaller.Resources;
using CreamInstaller.Utility;
using static CreamInstaller.Resources.Resources;
namespace CreamInstaller;
public enum Platform
@@ -34,12 +37,14 @@ internal sealed class Selection : IEquatable<Selection>
internal readonly string RootDirectory;
internal readonly TreeNode TreeNode;
internal string Icon;
internal bool UseExtraProtection;
internal bool UseProxy;
internal string Proxy;
internal string Product;
internal string Publisher;
internal string SubIcon;
internal string Website;
internal InstalledUnlocker InstalledUnlocker;
internal IEnumerable<string> GetAvailableProxies()
{
@@ -134,6 +139,100 @@ internal sealed class Selection : IEquatable<Selection>
internal static Selection FromId(Platform platform, string gameId) =>
All.Keys.FirstOrDefault(s => s.Platform == platform && s.Id == gameId);
internal InstalledUnlocker DetectInstalledUnlocker()
{
foreach (string directory in DllDirectories)
{
if (Platform is Platform.Steam or Platform.Paradox)
{
// Use uniquely-named config files to distinguish CreamAPI from SmokeAPI.
// Both share steam_api_o.dll so the _o files alone are ambiguous.
directory.GetSmokeApiComponents(out _, out _, out _, out _, out string smokeOldConfig,
out string smokeConfig, out _, out _, out _);
if (smokeConfig.FileExists() || smokeOldConfig.FileExists())
return InstalledUnlocker.SmokeAPI;
directory.GetCreamApiComponents(out _, out _, out _, out _, out string creamConfig);
if (creamConfig.FileExists())
{
ReadCreamApiConfig(creamConfig);
return InstalledUnlocker.CreamAPI;
}
// Fallback: config was deleted but _o files remain — identify by replacement DLL content
directory.GetSmokeApiComponents(out string smokeApi32, out string api32_o,
out string smokeApi64, out string api64_o, out _, out _, out _, out _, out _);
if (api32_o.FileExists() || api64_o.FileExists())
{
if ((smokeApi32.FileExists() && smokeApi32.IsResourceFile(ResourceIdentifier.Steamworks32))
|| (smokeApi64.FileExists() && smokeApi64.IsResourceFile(ResourceIdentifier.Steamworks64)))
return InstalledUnlocker.SmokeAPI;
return InstalledUnlocker.CreamAPI;
}
}
if (Platform is Platform.Epic or Platform.Paradox)
{
directory.GetScreamApiComponents(out _, out string api32_o, out _, out string api64_o,
out _, out string config, out _, out _);
if (config.FileExists() || api32_o.FileExists() || api64_o.FileExists())
return InstalledUnlocker.ScreamAPI;
}
if (Platform is Platform.Ubisoft)
{
directory.GetUplayR1Components(out _, out string api32_o, out _, out string api64_o,
out string config, out _);
if (config.FileExists() || api32_o.FileExists() || api64_o.FileExists())
return InstalledUnlocker.UplayR1;
directory.GetUplayR2Components(out _, out _, out _, out api32_o, out _, out api64_o,
out config, out _);
if (config.FileExists() || api32_o.FileExists() || api64_o.FileExists())
return InstalledUnlocker.UplayR2;
}
}
foreach ((string directory, _) in ExecutableDirectories)
{
directory.GetKoaloaderComponents(out _, out string config, out _);
if (directory.GetKoaloaderProxies().Any(proxy =>
proxy.FileExists() && proxy.IsResourceFile(ResourceIdentifier.Koaloader))
|| config.FileExists())
return InstalledUnlocker.Koaloader;
}
return InstalledUnlocker.None;
}
private void ReadCreamApiConfig(string configPath)
{
try
{
if (!configPath.FileExists())
return;
string[] lines = File.ReadAllLines(configPath);
foreach (string line in lines)
{
string trimmed = line.Trim();
if (trimmed.StartsWith("extraprotection", StringComparison.OrdinalIgnoreCase))
{
string[] parts = trimmed.Split('=');
if (parts.Length == 2)
{
string value = parts[1].Trim();
UseExtraProtection = value.Equals("true", StringComparison.OrdinalIgnoreCase);
}
break;
}
}
}
catch
{
// If we can't read the config, leave UseExtraProtection at its default value
}
}
public override bool Equals(object obj) => ReferenceEquals(this, obj) || obj is Selection other && Equals(other);
public override int GetHashCode() => HashCode.Combine(Id, (int)Platform);
+8 -5
View File
@@ -22,17 +22,20 @@ internal sealed class SelectionDLC : IEquatable<SelectionDLC>
internal readonly string Name;
internal readonly TreeNode TreeNode;
internal readonly DLCType Type;
internal readonly string GameId;
internal string Icon;
internal string Product;
internal string Publisher;
private Selection selection;
private SelectionDLC(DLCType type, string id, string name)
private SelectionDLC(DLCType type, string gameId, string id, string name)
{
Type = type;
GameId = gameId;
Id = id;
Name = name;
TreeNode = new() { Tag = Type, Name = Id, Text = Name };
_ = All.TryAdd(this, 0);
}
internal bool Enabled
@@ -65,15 +68,15 @@ internal sealed class SelectionDLC : IEquatable<SelectionDLC>
public bool Equals(SelectionDLC other)
=> other is not null && (ReferenceEquals(this, other) ||
Type == other.Type && Selection?.Id == other.Selection?.Id && Id == other.Id);
Type == other.Type && GameId == other.GameId && Id == other.Id);
internal static SelectionDLC GetOrCreate(DLCType type, string gameId, string id, string name)
=> FromId(type, gameId, id) ?? new SelectionDLC(type, id, name);
=> FromId(type, gameId, id) ?? new SelectionDLC(type, gameId, id, name);
internal static SelectionDLC FromId(DLCType type, string gameId, string dlcId)
=> All.Keys.FirstOrDefault(dlc => dlc.Type == type && dlc.Selection?.Id == gameId && dlc.Id == dlcId);
=> All.Keys.FirstOrDefault(dlc => dlc.Type == type && dlc.GameId == gameId && dlc.Id == dlcId);
public override bool Equals(object obj) => ReferenceEquals(this, obj) || obj is SelectionDLC other && Equals(other);
public override int GetHashCode() => HashCode.Combine((int)Type, Selection?.Id, Id);
public override int GetHashCode() => HashCode.Combine((int)Type, GameId, Id);
}
+96 -13
View File
@@ -13,23 +13,59 @@ namespace CreamInstaller.Utility;
internal static class HttpClientManager
{
internal static HttpClient HttpClient;
private static readonly object _lock = new();
private static HttpClient _httpClient;
private static SocketsHttpHandler _handler;
internal static HttpClient HttpClient
{
get
{
lock (_lock)
{
return _httpClient;
}
}
}
private static readonly ConcurrentDictionary<string, string> HttpContentCache = new();
internal static void Setup()
{
HttpClient = new();
if (CreamInstaller.Platforms.Epic.EpicStore.EpicBool)
lock (_lock)
{
HttpClient.DefaultRequestHeaders.UserAgent.Add(new("EpicGamesLauncher", "18.9.0-45233261+++Portal+Release-Live"));
CreamInstaller.Platforms.Epic.EpicStore.EpicBool = false;
// If already set up, don't recreate to avoid socket exhaustion
if (_httpClient != null)
return;
// Create a SocketsHttpHandler with proper pooling and lifecycle settings
_handler = new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(10), // Rotate connections every 10 minutes to respect DNS changes
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2), // Close idle connections after 2 minutes
MaxConnectionsPerServer = 10, // Reasonable concurrent connection limit
EnableMultipleHttp2Connections = true
};
// Create HttpClient with the handler
_httpClient = new HttpClient(_handler, disposeHandler: false)
{
Timeout = TimeSpan.FromSeconds(30) // 30 second timeout for all requests
};
// Set user agent based on context
if (CreamInstaller.Platforms.Epic.EpicStore.EpicBool)
{
_httpClient.DefaultRequestHeaders.UserAgent.Add(new("EpicGamesLauncher", "18.9.0-45233261+++Portal+Release-Live"));
CreamInstaller.Platforms.Epic.EpicStore.EpicBool = false;
}
else
{
_httpClient.DefaultRequestHeaders.UserAgent.Add(new(Program.Name, Program.Version));
}
_httpClient.DefaultRequestHeaders.AcceptLanguage.Add(new(CultureInfo.CurrentCulture.ToString()));
}
else
{
HttpClient.DefaultRequestHeaders.UserAgent.Add(new(Program.Name, Program.Version));
}
HttpClient.DefaultRequestHeaders.AcceptLanguage.Add(new(CultureInfo.CurrentCulture.ToString()));
}
internal static async Task<string> EnsureGet(string url)
@@ -52,16 +88,31 @@ internal static class HttpClientManager
if (e.StatusCode != HttpStatusCode.TooManyRequests)
{
#if DEBUG
DebugForm.Current.Log("Get request failed to " + url + ": " + e, LogTextBox.Warning);
string statusInfo = e.StatusCode.HasValue ? $" (HTTP {(int)e.StatusCode.Value})" : "";
DebugForm.Current.Log($"Get request failed to {url}{statusInfo}: {e}", LogTextBox.Warning);
#endif
return null;
}
#if DEBUG
DebugForm.Current.Log("Too many requests to " + url, LogTextBox.Error);
DebugForm.Current.Log($"Too many requests to {url} (HTTP 429 - Rate Limited)", LogTextBox.Error);
#endif
// do something special?
return null;
}
catch (TaskCanceledException)
{
#if DEBUG
DebugForm.Current.Log("Get request timed out for " + url, LogTextBox.Warning);
#endif
return null;
}
catch (OperationCanceledException)
{
#if DEBUG
DebugForm.Current.Log("Get request was cancelled for " + url, LogTextBox.Warning);
#endif
return null;
}
#if DEBUG
catch (Exception e)
{
@@ -88,5 +139,37 @@ internal static class HttpClientManager
}
}
internal static void Dispose() => HttpClient?.Dispose();
/// <summary>
/// Creates a new HttpClient for isolated/one-off use cases.
/// The caller is responsible for disposing the returned client.
/// </summary>
internal static HttpClient CreateIsolatedClient(TimeSpan? timeout = null)
{
var handler = new SocketsHttpHandler
{
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
PooledConnectionIdleTimeout = TimeSpan.FromMinutes(1),
MaxConnectionsPerServer = 5
};
var client = new HttpClient(handler, disposeHandler: true)
{
Timeout = timeout ?? TimeSpan.FromSeconds(30)
};
client.DefaultRequestHeaders.UserAgent.ParseAdd($"{Program.Name}/{Program.Version}");
return client;
}
internal static void Dispose()
{
lock (_lock)
{
_httpClient?.Dispose();
_httpClient = null;
_handler?.Dispose();
_handler = null;
}
}
}
+115
View File
@@ -6,10 +6,42 @@ using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
using CreamInstaller;
using Newtonsoft.Json;
namespace CreamInstaller.Utility;
internal enum InstalledUnlocker
{
None = 0,
CreamAPI,
SmokeAPI,
ScreamAPI,
UplayR1,
UplayR2,
Koaloader
}
internal sealed class InstalledDlcRecord
{
public string DlcType { get; set; }
public string Id { get; set; }
public string Name { get; set; }
}
internal sealed class InstalledGameRecord
{
public Platform Platform { get; set; }
public string Id { get; set; }
public string Name { get; set; }
public string RootDirectory { get; set; }
public InstalledUnlocker Unlocker { get; set; }
public bool UseProxy { get; set; }
public string Proxy { get; set; }
public bool UseExtraProtection { get; set; }
public List<InstalledDlcRecord> Dlc { get; set; } = [];
}
internal static class ProgramData
{
private static readonly string DirectoryPathOld =
@@ -29,6 +61,8 @@ internal static class ProgramData
private static readonly string ProgramChoicesPath = DirectoryPath + @"\choices.json";
private static readonly string DlcChoicesPath = DirectoryPath + @"\dlc.json";
private static readonly string KoaloaderProxyChoicesPath = DirectoryPath + @"\proxies.json";
private static readonly string ExtraProtectionChoicesPath = DirectoryPath + @"\extraprotection.json";
private static readonly string InstalledGamesPath = DirectoryPath + @"\installed.json";
internal static readonly string LogPath = DirectoryPath + @"\scan.log";
@@ -229,4 +263,85 @@ internal static class ProgramData
// ignored
}
}
internal static IEnumerable<(Platform platform, string id)> ReadExtraProtectionChoices()
{
if (ExtraProtectionChoicesPath.FileExists())
try
{
if (JsonConvert.DeserializeObject(ExtraProtectionChoicesPath.ReadFile(),
typeof(IEnumerable<(Platform platform, string id)>)) is
IEnumerable<(Platform platform, string id)> choices)
return choices;
}
catch
{
// ignored
}
return [];
}
internal static void WriteExtraProtectionChoices(IEnumerable<(Platform platform, string id)> choices)
{
try
{
if (choices is null || !choices.Any())
ExtraProtectionChoicesPath.DeleteFile();
else
ExtraProtectionChoicesPath.WriteFile(JsonConvert.SerializeObject(choices));
}
catch
{
// ignored
}
}
internal static List<InstalledGameRecord> ReadInstalledGames()
{
if (InstalledGamesPath.FileExists())
try
{
if (JsonConvert.DeserializeObject<List<InstalledGameRecord>>(InstalledGamesPath.ReadFile()) is
{ } records)
return records;
}
catch
{
// ignored
}
return [];
}
internal static void WriteInstalledGames(IEnumerable<InstalledGameRecord> records)
{
try
{
List<InstalledGameRecord> list = records?.ToList() ?? [];
if (list.Count == 0)
InstalledGamesPath.DeleteFile();
else
InstalledGamesPath.WriteFile(JsonConvert.SerializeObject(list, Formatting.Indented));
}
catch
{
// ignored
}
}
internal static void UpsertInstalledGame(InstalledGameRecord record)
{
List<InstalledGameRecord> records = ReadInstalledGames();
_ = records.RemoveAll(r => r.Platform == record.Platform && r.Id == record.Id);
records.Add(record);
WriteInstalledGames(records);
}
internal static void RemoveInstalledGame(Platform platform, string id)
{
List<InstalledGameRecord> records = ReadInstalledGames();
if (records.RemoveAll(r => r.Platform == platform && r.Id == id) > 0)
WriteInstalledGames(records);
}
}
+22
View File
@@ -31,6 +31,17 @@ internal static class ThemeManager
private static readonly Color DarkComboBorder = DarkBorder; // #3F3F46
private static readonly Color DarkComboText = DarkFore; // #D4D4D4
// Badge colors for unlockers
private static readonly Color CreamAPIBadgeBack = ColorTranslator.FromHtml("#C8A078"); // Creamy latte
private static readonly Color CreamAPIBadgeBackHighlight = ColorTranslator.FromHtml("#B48C64");
private static readonly Color CreamAPIBadgeBorder = ColorTranslator.FromHtml("#DCB48C");
private static readonly Color SmokeAPIBadgeBack = ColorTranslator.FromHtml("#69696E"); // Smoky grey
private static readonly Color SmokeAPIBadgeBackHighlight = ColorTranslator.FromHtml("#5A5A5F");
private static readonly Color SmokeAPIBadgeBorder = ColorTranslator.FromHtml("#8C8C91");
private static readonly Color DefaultBadgeBack = ColorTranslator.FromHtml("#008C46"); // Default green
private static readonly Color DefaultBadgeBackHighlight = ColorTranslator.FromHtml("#00783C");
private static readonly Color DefaultBadgeBorder = ColorTranslator.FromHtml("#00B45A");
// ----------------------------
// Light mode colors (system defaults)
// ----------------------------
@@ -76,6 +87,17 @@ internal static class ThemeManager
internal static Color CustomTreeViewComboTextColor => IsDark ? DarkComboText : LightComboText;
// Badge colors for unlockers
internal static Color CreamAPIBadgeBackgroundColor => CreamAPIBadgeBack;
internal static Color CreamAPIBadgeBackgroundHighlightColor => CreamAPIBadgeBackHighlight;
internal static Color CreamAPIBadgeBorderColor => CreamAPIBadgeBorder;
internal static Color SmokeAPIBadgeBackgroundColor => SmokeAPIBadgeBack;
internal static Color SmokeAPIBadgeBackgroundHighlightColor => SmokeAPIBadgeBackHighlight;
internal static Color SmokeAPIBadgeBorderColor => SmokeAPIBadgeBorder;
internal static Color DefaultBadgeBackgroundColor => DefaultBadgeBack;
internal static Color DefaultBadgeBackgroundHighlightColor => DefaultBadgeBackHighlight;
internal static Color DefaultBadgeBorderColor => DefaultBadgeBorder;
// -----------------------------------------------------------------
// Public / Internal API
// -----------------------------------------------------------------