20 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
Frog 54592230c3 Increment App Version
- Increase app version to 5.0.2.3
2026-05-27 01:33:35 -07:00
Frog 34cb3b862c Fix Light Mode Selection Bar
- Adjusts the selection bar so it properly selects the whole field.
- Adjusted the color of the selection bar to be a lighter blue so text is easier to read.
2026-05-27 01:32:24 -07:00
Frog 8040e6bcdb Fix Dark Mode Expander / Selection Bar
- Fixes missing expander in dark mode, which occured when I was fixing the checkboxes. (Woops)
- Fixes the selection bar in dark mode, now properly selects the proxy button as well instead of being cut off. (Light mode still looks like shit though.)
2026-05-27 01:28:28 -07:00
Frog 593f396c54 Add Test Game Generator for Steam and Epic
- Added Test Game Generator to the Debug window for creating fake installed game entries for testing
- Updated ThemeManager to properly theme ListBox controls in dark mode
- Labels now use transparent backgrounds for better appearance
2026-05-27 01:20:03 -07:00
Frog 2f993bfe3b Updated Preview Image
- Updated preview image to Dark Mode
2026-05-26 02:41:03 -07:00
Frog e9f8222d8e Increment App Version
- Increase app version to 5.0.2.2
2026-05-26 02:29:48 -07:00
Frog 68842aad9f Logging Infrastructure / Normalize Paths
- Added base logging infrastructure
- Create scan log during library/game scan in %ProgramData%\CreamInstaller\scan.log
- Normalize Steam library paths (libraryfolders.vdf) using Path.GetFullPath + ResolvePath to handle slashes, casing, and drive changes
- Diagnostics.ResolvePath: wrap GetFileSystemInfos in try/catch and guard against empty results to prevent IndexOutOfRangeException (May assist with issues on slow or intermittently accessible external drives)
2026-05-26 02:11:15 -07:00
Frog 1d5dc4ac8c Increment Version
- Increase version to 5.0.2.1
2026-05-25 16:18:01 -07:00
Frog 31ca8a947f Fix: CustomTreeView Checkbox Themes
- Further adjust the Proxy/CustomTreeView checkbox theme, doesn't perfectly match the top settings checkboxes, but it's good enough.
2026-05-25 14:57:38 -07:00
Frog 558612f098 fix: dark mode checkboxes in CustomTreeView
- Added ThemeManager.DrawDarkCheckBox to render dark-themed rounded checkboxes
- Fixed TreeView and Proxy checkboxes showing white backgrounds in dark mode due to CheckBoxRenderer drawing opaque system-themed glyphs. > Still need to fix the actual checkmark to match the top checkboxes.
- Disabled DrawDefault for checkbox tree nodes in dark mode and manually draw the row background, text, and checkbox glyphs.
- Checkbox glyph sizing now uses CheckBoxRenderer.GetGlyphSize for proper DPI scaling. (Probably)
2026-05-25 14:54:51 -07:00
Frog b7067c2621 Change Game Filter Text to PlaceHolder Text
- Removed the game search label in favor of textbox placeholder text.
 - Added methods to ensure the placeholder text renders properly
- Added inline comments to explain what NativeMethods is for
2026-05-25 02:35:50 -07:00
Frog fe55efc072 Added Game Search / Filter When Scanning Games Closes #15
- Added a "Game search:" label and text box above the game list in
  SelectDialogForm, allowing users to filter the program/game list
  by name in real-time (case-insensitive substring match).
- Added TextBox theming support to ThemeManager for both dark and
  light modes, ensuring the new search field conforms to the active
  theme.
2026-05-25 02:18:56 -07:00
Frog 39097c27ef Add Dev CI Builds
- Added CI action workflow for dev branch
2026-03-23 23:55:06 -07:00
29 changed files with 2370 additions and 410 deletions
+49
View File
@@ -0,0 +1,49 @@
name: Dev CI Builds
on:
push:
branches:
- dev
workflow_dispatch:
jobs:
build:
runs-on: windows-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore CreamInstaller.sln
- name: Build Release
run: dotnet build CreamInstaller.sln --configuration Release --no-restore
- name: Publish single-file
run: dotnet publish CreamInstaller.sln -c Release -r win-x64 -p:PublishSingleFile=true --self-contained true --output ./publish
- name: Set short commit SHA and branch name
id: vars
run: |
$shortSha = $env:GITHUB_SHA.Substring(0,7)
$branch = $env:GITHUB_REF_NAME
Write-Output "shortSha=$shortSha" >> $env:GITHUB_ENV
Write-Output "branch=$branch" >> $env:GITHUB_ENV
shell: pwsh
- name: Rename EXE with branch and short commit SHA
run: |
Rename-Item -Path ./publish/CreamInstaller.exe -NewName "CreamInstaller-CI-$env:branch-$env:shortSha.exe"
shell: pwsh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: CreamInstaller-CI-${{ env.branch }}-${{ env.shortSha }}
path: ./publish/CreamInstaller-CI-${{ env.branch }}-${{ env.shortSha }}.exe
+198 -11
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();
@@ -75,11 +78,14 @@ internal sealed class CustomTreeView : TreeView
private void DrawTreeNode(object sender, DrawTreeNodeEventArgs e)
{
e.DrawDefault = true;
TreeNode node = e.Node;
if (node is not { IsVisible: true })
{
e.DrawDefault = true;
return;
}
bool dark = Program.DarkModeEnabled;
bool highlighted = (e.State & TreeNodeStates.Selected) == TreeNodeStates.Selected && Focused;
Graphics graphics = e.Graphics;
@@ -103,12 +109,95 @@ internal sealed class CustomTreeView : TreeView
}
}
Form form = FindForm();
if (dark && CheckBoxes)
{
// In dark mode we take full ownership of the row so the system never
// gets a chance to paint a light-background checkbox.
e.DrawDefault = false;
// Row background
Rectangle rowRect = new(0, node.Bounds.Top, ClientSize.Width, node.Bounds.Height);
graphics.FillRectangle(highlighted ? selectionBrush : backBrush, rowRect);
// Node text
Font nodeFont = node.NodeFont ?? Font;
Color textColor = Enabled ? ForeColor : SystemColors.GrayText;
TextRenderer.DrawText(graphics, node.Text, nodeFont,
new Point(node.Bounds.Left, node.Bounds.Top + 1), textColor, TextFormatFlags.Default);
// Checkbox glyph pure GDI so it matches the dark-themed CheckBox controls
CheckBoxState cbState = node.Checked
? (Enabled ? CheckBoxState.CheckedNormal : CheckBoxState.CheckedDisabled)
: (Enabled ? CheckBoxState.UncheckedNormal : CheckBoxState.UncheckedDisabled);
Size cbSize = CheckBoxRenderer.GetGlyphSize(graphics, cbState);
int cbX = node.Bounds.Left - cbSize.Width - 2;
int cbY = node.Bounds.Top + node.Bounds.Height / 2 - cbSize.Height / 2;
ThemeManager.DrawDarkCheckBox(graphics, new Point(cbX, cbY), cbSize, node.Checked, Enabled);
// Expander glyph (expand/collapse) the system skips this when DrawDefault=false
if (node.Nodes.Count > 0)
{
int indent = Indent;
int level = node.Level;
int glyphSize = 13;
int glyphX = level * indent + (indent - glyphSize) / 2 + (ShowRootLines ? 0 : -indent);
int glyphY = node.Bounds.Top + node.Bounds.Height / 2 - glyphSize / 2;
Rectangle glyphRect = new(glyphX, glyphY, glyphSize, glyphSize);
Color glyphBorder = Color.FromArgb(0x6B, 0x6B, 0x6B);
Color glyphBack = Color.FromArgb(0x2D, 0x2D, 0x2D);
Color glyphFore = Color.FromArgb(0xD4, 0xD4, 0xD4);
using (SolidBrush backFill = new(glyphBack))
graphics.FillRectangle(backFill, glyphRect);
using (Pen borderPen = new(glyphBorder))
graphics.DrawRectangle(borderPen, glyphRect);
int mid = glyphY + glyphSize / 2;
int left = glyphX + 3;
int right = glyphX + glyphSize - 3;
using (Pen linePen = new(glyphFore))
{
graphics.DrawLine(linePen, left, mid, right, mid); // horizontal minus
if (!node.IsExpanded)
graphics.DrawLine(linePen, glyphX + glyphSize / 2, glyphY + 3, glyphX + glyphSize / 2, glyphY + glyphSize - 3); // vertical plus
}
}
}
else
{
if (highlighted && CheckBoxes)
{
// In light mode, take ownership of the row when selected so the
// highlight fills the full width (same approach as dark mode).
e.DrawDefault = false;
Rectangle rowRect = new(0, node.Bounds.Top, ClientSize.Width, node.Bounds.Height);
graphics.FillRectangle(selectionBrush, rowRect);
Font nodeFont = node.NodeFont ?? Font;
Color textColor = Enabled ? ForeColor : SystemColors.GrayText;
TextRenderer.DrawText(graphics, node.Text, nodeFont,
new Point(node.Bounds.Left, node.Bounds.Top + 1), textColor, TextFormatFlags.Default);
CheckBoxState cbState = node.Checked
? (Enabled ? CheckBoxState.CheckedNormal : CheckBoxState.CheckedDisabled)
: (Enabled ? CheckBoxState.UncheckedNormal : CheckBoxState.UncheckedDisabled);
Size cbSize = CheckBoxRenderer.GetGlyphSize(graphics, cbState);
Point cbPoint = new(node.Bounds.Left - cbSize.Width - 2,
node.Bounds.Top + node.Bounds.Height / 2 - cbSize.Height / 2);
CheckBoxRenderer.DrawCheckBox(graphics, cbPoint, cbState);
}
else
{
e.DrawDefault = true;
}
}
Font font = node.NodeFont ?? Font;
Brush brush = highlighted ? (Brush)selectionBrush : backBrush;
Rectangle bounds = node.Bounds;
Rectangle selectionBounds = bounds;
Form form = FindForm();
if (form is not SelectForm and not SelectDialogForm)
return;
@@ -168,18 +257,106 @@ internal sealed class CustomTreeView : TreeView
graphics.FillRectangle(brush, bounds);
}
CheckBoxState checkBoxState = selection.UseProxy
? Enabled ? CheckBoxState.CheckedPressed : CheckBoxState.CheckedDisabled
: Enabled
? CheckBoxState.UncheckedPressed
: CheckBoxState.UncheckedDisabled;
size = CheckBoxRenderer.GetGlyphSize(graphics, checkBoxState);
// 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);
size = CheckBoxRenderer.GetGlyphSize(graphics, proxyState);
bounds = bounds with { X = bounds.X + bounds.Width, Width = size.Width };
selectionBounds = new(selectionBounds.Location, selectionBounds.Size + bounds.Size with { Height = 0 });
Rectangle checkBoxBounds = bounds;
graphics.FillRectangle(backBrush, bounds);
graphics.FillRectangle(brush, bounds);
point = new(bounds.Left, bounds.Top + bounds.Height / 2 - size.Height / 2 - 1);
CheckBoxRenderer.DrawCheckBox(graphics, point, checkBoxState);
if (dark)
ThemeManager.DrawDarkCheckBox(graphics, point, size, selection.UseProxy, Enabled);
else
CheckBoxRenderer.DrawCheckBox(graphics, point, proxyState);
text = ProxyToggleString;
size = TextRenderer.MeasureText(graphics, text, font);
@@ -187,7 +364,7 @@ internal sealed class CustomTreeView : TreeView
bounds = bounds with { X = bounds.X + bounds.Width, Width = size.Width + left };
selectionBounds = new(selectionBounds.Location, selectionBounds.Size + bounds.Size with { Height = 0 });
checkBoxBounds = new(checkBoxBounds.Location, checkBoxBounds.Size + bounds.Size with { Height = 0 });
graphics.FillRectangle(backBrush, bounds);
graphics.FillRectangle(brush, bounds);
point = new(bounds.Location.X - 1 + left, bounds.Location.Y + 1);
TextRenderer.DrawText(graphics, text, font, point,
Enabled ? ThemeManager.CustomTreeViewProxyColor : ThemeManager.CustomTreeViewDisabledProxyColor,
@@ -312,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;
}
}
}
+2 -2
View File
@@ -4,8 +4,8 @@
<TargetFramework>net8.0-windows10.0.22621.0</TargetFramework>
<UseWindowsForms>True</UseWindowsForms>
<ApplicationIcon>Resources\program.ico</ApplicationIcon>
<Version>5.0.2.0</Version>
<Copyright>2025, FroggMaster (https://github.com/FroggMaster)</Copyright>
<Version>5.0.2.3</Version>
<Copyright>2026, FroggMaster (https://github.com/FroggMaster)</Copyright>
<Company>CreamInstaller</Company>
<Product>Automatic DLC Unlocker Installer &amp; Configuration Generator</Product>
<StartupObject>CreamInstaller.Program</StartupObject>
+20 -3
View File
@@ -32,16 +32,31 @@ partial class DebugForm
private void InitializeComponent()
{
debugTextBox = new RichTextBox();
testGameButton = new Button();
SuspendLayout();
//
// testGameButton
//
testGameButton.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
testGameButton.AutoSize = true;
testGameButton.AutoSizeMode = AutoSizeMode.GrowAndShrink;
testGameButton.Location = new System.Drawing.Point(10, 10);
testGameButton.Name = "testGameButton";
testGameButton.Padding = new Padding(3, 0, 3, 0);
testGameButton.Size = new System.Drawing.Size(540, 25);
testGameButton.TabIndex = 1;
testGameButton.Text = "Test Game";
testGameButton.UseVisualStyleBackColor = true;
testGameButton.Click += OnTestGame;
//
// debugTextBox
//
debugTextBox.Dock = DockStyle.Fill;
debugTextBox.Location = new System.Drawing.Point(10, 10);
debugTextBox.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
debugTextBox.Location = new System.Drawing.Point(10, 41);
debugTextBox.Name = "debugTextBox";
debugTextBox.ReadOnly = true;
debugTextBox.ScrollBars = RichTextBoxScrollBars.ForcedBoth;
debugTextBox.Size = new System.Drawing.Size(540, 317);
debugTextBox.Size = new System.Drawing.Size(540, 286);
debugTextBox.TabIndex = 0;
debugTextBox.TabStop = false;
debugTextBox.Text = "";
@@ -52,6 +67,7 @@ partial class DebugForm
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new System.Drawing.Size(560, 337);
ControlBox = false;
Controls.Add(testGameButton);
Controls.Add(debugTextBox);
FormBorderStyle = FormBorderStyle.FixedSingle;
MaximizeBox = false;
@@ -68,4 +84,5 @@ partial class DebugForm
#endregion
private RichTextBox debugTextBox;
private Button testGameButton;
}
+15 -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;
}
}
}
@@ -81,4 +87,10 @@ internal sealed partial class DebugForm : CustomForm
debugTextBox.AppendText(text, color, true);
});
}
private void OnTestGame(object sender, EventArgs e)
{
using TestGameForm form = new(this);
_ = form.ShowDialog(this);
}
}
+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();
}
}
}
+15 -2
View File
@@ -33,6 +33,7 @@ namespace CreamInstaller.Forms
saveButton = new Button();
uninstallAllButton = new Button();
selectionTreeView = new CustomTreeView();
filterTextBox = new System.Windows.Forms.TextBox();
groupBox.SuspendLayout();
allCheckBoxFlowPanel.SuspendLayout();
SuspendLayout();
@@ -51,15 +52,25 @@ namespace CreamInstaller.Forms
acceptButton.Text = "OK";
acceptButton.UseVisualStyleBackColor = true;
//
// filterTextBox
//
filterTextBox.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
filterTextBox.Location = new System.Drawing.Point(12, 14);
filterTextBox.Name = "filterTextBox";
filterTextBox.PlaceholderText = "Enter the name of a game to search";
filterTextBox.Size = new System.Drawing.Size(524, 23);
filterTextBox.TabIndex = 0;
filterTextBox.TextChanged += OnFilterTextChanged;
//
// groupBox
//
groupBox.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
groupBox.Controls.Add(selectionTreeView);
groupBox.Controls.Add(allCheckBoxFlowPanel);
groupBox.Location = new System.Drawing.Point(12, 12);
groupBox.Location = new System.Drawing.Point(12, 43);
groupBox.MinimumSize = new System.Drawing.Size(240, 40);
groupBox.Name = "groupBox";
groupBox.Size = new System.Drawing.Size(524, 225);
groupBox.Size = new System.Drawing.Size(524, 194);
groupBox.TabIndex = 3;
groupBox.TabStop = false;
groupBox.Text = "Choices";
@@ -188,6 +199,7 @@ namespace CreamInstaller.Forms
Controls.Add(cancelButton);
Controls.Add(acceptButton);
Controls.Add(groupBox);
Controls.Add(filterTextBox);
FormBorderStyle = FormBorderStyle.FixedSingle;
MaximizeBox = false;
MinimizeBox = false;
@@ -215,5 +227,6 @@ namespace CreamInstaller.Forms
private Button saveButton;
private CheckBox sortCheckBox;
private Button uninstallAllButton;
private System.Windows.Forms.TextBox filterTextBox;
}
}
+33 -6
View File
@@ -10,6 +10,7 @@ namespace CreamInstaller.Forms;
internal sealed partial class SelectDialogForm : CustomForm
{
private readonly List<(Platform platform, string id, string name)> selected = new();
private readonly List<(Platform platform, string id, string name, bool alreadySelected)> allChoices = new();
internal SelectDialogForm(IWin32Window owner) : base(owner)
{
@@ -28,12 +29,12 @@ internal sealed partial class SelectDialogForm : CustomForm
allCheckBox.Enabled = false;
acceptButton.Enabled = false;
selectionTreeView.AfterCheck += OnTreeNodeChecked;
foreach ((Platform platform, string id, string name, bool alreadySelected) in potentialChoices)
{
TreeNode node = new() { Tag = platform, Name = id, Text = name, Checked = alreadySelected };
OnTreeNodeChecked(node);
_ = selectionTreeView.Nodes.Add(node);
}
allChoices.Clear();
allChoices.AddRange(potentialChoices);
foreach ((Platform platform, string id, string name, bool alreadySelected) in allChoices)
if (alreadySelected)
selected.Add((platform, id, name));
ApplyFilter();
if (selected.Count < 1)
OnLoad(null, null);
@@ -70,6 +71,32 @@ internal sealed partial class SelectDialogForm : CustomForm
allCheckBox.CheckedChanged += OnAllCheckBoxChanged;
}
private void OnFilterTextChanged(object sender, EventArgs e) => ApplyFilter();
private void ApplyFilter()
{
string filter = filterTextBox.Text.Trim();
selectionTreeView.AfterCheck -= OnTreeNodeChecked;
selectionTreeView.BeginUpdate();
selectionTreeView.Nodes.Clear();
bool hasSelections = selected.Count > 0;
foreach ((Platform platform, string id, string name, bool alreadySelected) in allChoices)
{
if (filter.Length > 0 && name.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0)
continue;
bool checkedState = hasSelections
? selected.Any(s => s.platform == platform && s.id == id)
: alreadySelected;
TreeNode node = new() { Tag = platform, Name = id, Text = name, Checked = checkedState };
_ = selectionTreeView.Nodes.Add(node);
}
selectionTreeView.EndUpdate();
selectionTreeView.AfterCheck += OnTreeNodeChecked;
allCheckBox.CheckedChanged -= OnAllCheckBoxChanged;
allCheckBox.Checked = selectionTreeView.Nodes.Count > 0 && selectionTreeView.Nodes.Cast<TreeNode>().All(n => n.Checked);
allCheckBox.CheckedChanged += OnAllCheckBoxChanged;
}
private void OnResize(object s, EventArgs e)
=> Text = TextRenderer.MeasureText(Program.ApplicationName, Font).Width > Size.Width - 100
? Program.ApplicationNameShort
+199 -39
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,25 +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);
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))
{
@@ -687,6 +697,7 @@ internal sealed partial class SelectForm : CustomForm
}
OnLoadSelections(null, null);
await LoadSavedInstalledGames();
HideProgressBar();
selectionTreeView.Enabled = !Selection.All.IsEmpty;
allCheckBox.Enabled = selectionTreeView.Enabled;
@@ -703,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)
@@ -972,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();
}
}
}
@@ -1063,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)
{
@@ -1097,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();
}
@@ -1105,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)
{
@@ -1147,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();
}
@@ -1156,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)
{
@@ -1170,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();
@@ -1185,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;
@@ -1219,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)
+215
View File
@@ -0,0 +1,215 @@
using System.ComponentModel;
using System.Windows.Forms;
namespace CreamInstaller.Forms;
partial class TestGameForm
{
private IContainer components = null;
protected override void Dispose(bool disposing)
{
if (disposing && components is not null)
components.Dispose();
base.Dispose(disposing);
}
// All coordinates are based on ClientSize = 560 x 330
// Left margin = 12, right edge of usable area = 548 (560 - 12)
// Usable width = 536
private void InitializeComponent()
{
platformGroupBox = new GroupBox();
steamRadioButton = new RadioButton();
epicRadioButton = new RadioButton();
appIdLabel = new Label();
appIdTextBox = new TextBox();
gameNameLabel = new Label();
gameNameTextBox = new TextBox();
epicSearchButton = new Button();
epicResultsListBox = new ListBox();
dlcGroupBox = new GroupBox();
dlcListBox = new ListBox();
dlcIdLabel = new Label();
dlcIdTextBox = new TextBox();
dlcNameLabel = new Label();
dlcNameTextBox = new TextBox();
addDlcButton = new Button();
removeDlcButton = new Button();
generateButton = new Button();
clearButton = new Button();
closeButton = new Button();
statusLabel = new Label();
platformGroupBox.SuspendLayout();
dlcGroupBox.SuspendLayout();
SuspendLayout();
// ── Platform group box ── y=8, h=44
platformGroupBox.Location = new System.Drawing.Point(12, 8);
platformGroupBox.Size = new System.Drawing.Size(536, 44);
platformGroupBox.TabStop = false;
platformGroupBox.Text = "Platform";
platformGroupBox.Controls.Add(steamRadioButton);
platformGroupBox.Controls.Add(epicRadioButton);
steamRadioButton.AutoSize = true;
steamRadioButton.Checked = true;
steamRadioButton.Location = new System.Drawing.Point(10, 17);
steamRadioButton.TabStop = true;
steamRadioButton.Text = "Steam";
steamRadioButton.CheckedChanged += OnPlatformChanged;
epicRadioButton.AutoSize = true;
epicRadioButton.Location = new System.Drawing.Point(80, 17);
epicRadioButton.Text = "Epic";
epicRadioButton.CheckedChanged += OnPlatformChanged;
// ── App ID row ── y=62
appIdLabel.AutoSize = true;
appIdLabel.Location = new System.Drawing.Point(12, 66);
appIdLabel.Text = "App ID:";
appIdTextBox.Location = new System.Drawing.Point(105, 63);
appIdTextBox.Size = new System.Drawing.Size(443, 23);
appIdTextBox.PlaceholderText = "e.g. 480";
// ── Game Name row ── y=96
gameNameLabel.AutoSize = true;
gameNameLabel.Location = new System.Drawing.Point(12, 100);
gameNameLabel.Text = "Game Name:";
// Steam: full width; Epic: leaves room for Search button (75px + 4px gap)
gameNameTextBox.Location = new System.Drawing.Point(105, 97);
gameNameTextBox.Size = new System.Drawing.Size(443, 23);
epicSearchButton.Location = new System.Drawing.Point(468, 97);
epicSearchButton.Size = new System.Drawing.Size(80, 23);
epicSearchButton.Text = "Search";
epicSearchButton.Visible = false;
epicSearchButton.Click += OnEpicSearch;
// ── Epic results list ── y=130, same slot as DLC group
epicResultsListBox.Location = new System.Drawing.Point(12, 130);
epicResultsListBox.Size = new System.Drawing.Size(536, 80);
epicResultsListBox.Visible = false;
epicResultsListBox.SelectedIndexChanged += OnEpicResultSelected;
// ── DLC group box ── y=130, h=130
dlcGroupBox.Location = new System.Drawing.Point(12, 130);
dlcGroupBox.Size = new System.Drawing.Size(536, 130);
dlcGroupBox.TabStop = false;
dlcGroupBox.Text = "DLC Entries (Steam only)";
dlcGroupBox.Controls.Add(dlcListBox);
dlcGroupBox.Controls.Add(dlcIdLabel);
dlcGroupBox.Controls.Add(dlcIdTextBox);
dlcGroupBox.Controls.Add(dlcNameLabel);
dlcGroupBox.Controls.Add(dlcNameTextBox);
dlcGroupBox.Controls.Add(addDlcButton);
dlcGroupBox.Controls.Add(removeDlcButton);
dlcListBox.Location = new System.Drawing.Point(6, 20);
dlcListBox.Size = new System.Drawing.Size(524, 60);
// DLC row inside group box — left-to-right:
// "DLC ID:" label + 70px box + "DLC Name:" label + 160px box + "Add"(60) + "Remove"(70)
// Total: ~48 + 70 + ~72 + 160 + 60 + 70 = 480 (fits in 524)
dlcIdLabel.AutoSize = true;
dlcIdLabel.Location = new System.Drawing.Point(6, 92);
dlcIdLabel.Text = "DLC ID:";
dlcIdTextBox.Location = new System.Drawing.Point(62, 89);
dlcIdTextBox.Size = new System.Drawing.Size(70, 23);
dlcIdTextBox.PlaceholderText = "e.g. 12345";
dlcNameLabel.AutoSize = true;
dlcNameLabel.Location = new System.Drawing.Point(140, 92);
dlcNameLabel.Text = "DLC Name:";
dlcNameTextBox.Location = new System.Drawing.Point(216, 89);
dlcNameTextBox.Size = new System.Drawing.Size(184, 23);
dlcNameTextBox.PlaceholderText = "e.g. Test DLC";
addDlcButton.Location = new System.Drawing.Point(406, 89);
addDlcButton.Size = new System.Drawing.Size(52, 23);
addDlcButton.Text = "Add";
addDlcButton.Click += OnAddDlc;
removeDlcButton.Location = new System.Drawing.Point(462, 89);
removeDlcButton.Size = new System.Drawing.Size(62, 23);
removeDlcButton.Text = "Remove";
removeDlcButton.Click += OnRemoveDlc;
// ── Action buttons ── y=270
generateButton.Location = new System.Drawing.Point(12, 270);
generateButton.Size = new System.Drawing.Size(150, 26);
generateButton.Text = "Generate Test Game";
generateButton.Click += OnGenerate;
clearButton.Location = new System.Drawing.Point(168, 270);
clearButton.Size = new System.Drawing.Size(110, 26);
clearButton.Text = "Clear All Tests";
clearButton.Click += OnClearAll;
closeButton.Location = new System.Drawing.Point(284, 270);
closeButton.Size = new System.Drawing.Size(70, 26);
closeButton.Text = "Close";
closeButton.Click += OnClose;
// ── Status label ── y=302
statusLabel.Location = new System.Drawing.Point(12, 302);
statusLabel.Size = new System.Drawing.Size(536, 20);
statusLabel.Font = new System.Drawing.Font("Segoe UI", 8.25F);
// ── Form ──
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new System.Drawing.Size(560, 328);
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
StartPosition = FormStartPosition.CenterParent;
Text = "Test Game Generator";
Controls.Add(platformGroupBox);
Controls.Add(appIdLabel);
Controls.Add(appIdTextBox);
Controls.Add(gameNameLabel);
Controls.Add(gameNameTextBox);
Controls.Add(epicSearchButton);
Controls.Add(epicResultsListBox);
Controls.Add(dlcGroupBox);
Controls.Add(generateButton);
Controls.Add(clearButton);
Controls.Add(closeButton);
Controls.Add(statusLabel);
platformGroupBox.ResumeLayout(false);
platformGroupBox.PerformLayout();
dlcGroupBox.ResumeLayout(false);
dlcGroupBox.PerformLayout();
ResumeLayout(false);
PerformLayout();
}
private GroupBox platformGroupBox;
private RadioButton steamRadioButton;
private RadioButton epicRadioButton;
private Label appIdLabel;
private TextBox appIdTextBox;
private Label gameNameLabel;
private TextBox gameNameTextBox;
private Button epicSearchButton;
private ListBox epicResultsListBox;
private GroupBox dlcGroupBox;
private ListBox dlcListBox;
private Label dlcIdLabel;
private TextBox dlcIdTextBox;
private Label dlcNameLabel;
private TextBox dlcNameTextBox;
private Button addDlcButton;
private Button removeDlcButton;
private Button generateButton;
private Button clearButton;
private Button closeButton;
private Label statusLabel;
}
+361
View File
@@ -0,0 +1,361 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
using CreamInstaller.Components;
using CreamInstaller.Platforms.Epic;
using CreamInstaller.Platforms.Steam;
using CreamInstaller.Utility;
namespace CreamInstaller.Forms;
internal sealed partial class TestGameForm : CustomForm
{
private static readonly string TestGamesRoot =
Path.Combine(ProgramData.DirectoryPath, "TestGames");
private static readonly List<string> CreatedDirectories = [];
// Steam DLC entries per-form: (dlcId, dlcName)
private readonly List<(string id, string name)> dlcEntries = [];
// Cached Epic search results from the last search: (namespace, name)
private readonly List<(string ns, string name)> epicSearchResults = [];
private bool IsEpicMode => epicRadioButton.Checked;
internal TestGameForm(IWin32Window owner) : base(owner)
{
InitializeComponent();
appIdTextBox.Leave += OnAppIdLeave;
RefreshDlcList();
UpdatePlatformMode();
}
private void UpdatePlatformMode()
{
bool epic = IsEpicMode;
// App ID row: Steam only
appIdLabel.Visible = !epic;
appIdTextBox.Visible = !epic;
// Search button: Epic only — shrink the game name box to make room
epicSearchButton.Visible = epic;
gameNameTextBox.Size = new System.Drawing.Size(epic ? 354 : 443, 23);
// Placeholder text — call RefreshCueBanner to flush the Win32 cue so only one text shows
gameNameTextBox.PlaceholderText = epic ? "Enter game name and click Search" : "e.g. Spacewar";
NativeMethods.RefreshCueBanner(gameNameTextBox);
// DLC group and Epic results share the same vertical slot
dlcGroupBox.Visible = !epic;
epicResultsListBox.Visible = false; // hidden until search runs
if (!epic)
epicSearchResults.Clear();
SetStatus(epic
? "Enter a game name and click Search to find it on the Epic store."
: "Enter the App ID, then tab out to auto-detect the game name.");
}
private void OnPlatformChanged(object sender, EventArgs e) => UpdatePlatformMode();
// ── Steam: auto-detect name from AppID ──────────────────────────────────
private async void OnAppIdLeave(object sender, EventArgs e)
{
if (IsEpicMode)
return;
string appId = appIdTextBox.Text.Trim();
if (string.IsNullOrWhiteSpace(appId) || !int.TryParse(appId, out _))
return;
if (!string.IsNullOrWhiteSpace(gameNameTextBox.Text))
return;
SetStatus("Looking up game name . . .");
generateButton.Enabled = false;
string name = await Task.Run(async () =>
{
// 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
{
string json = await client.GetStringAsync(url);
Newtonsoft.Json.Linq.JObject root = Newtonsoft.Json.Linq.JObject.Parse(json);
string title = root[appId]?["data"]?["name"]?.ToString();
if (!string.IsNullOrWhiteSpace(title))
return title;
}
catch { /* fall through to SteamCMD */ }
CmdAppData cmdData = await SteamCMD.GetAppInfo(appId);
return cmdData?.Common?.Name;
});
generateButton.Enabled = true;
if (name is not null)
{
gameNameTextBox.Text = name;
SetStatus($"✓ Game name detected: {name}");
}
else
{
SetStatus("Could not auto-detect name — enter it manually.");
}
}
// ── Epic: search by name ─────────────────────────────────────────────────
private async void OnEpicSearch(object sender, EventArgs e)
{
string keyword = gameNameTextBox.Text.Trim();
if (string.IsNullOrWhiteSpace(keyword))
{
SetStatus("Enter a game name to search.");
return;
}
SetStatus("Searching Epic store . . .");
epicSearchButton.Enabled = false;
generateButton.Enabled = false;
epicResultsListBox.Items.Clear();
epicResultsListBox.Visible = false;
epicSearchResults.Clear();
List<(string ns, string name)> results = await EpicStore.QuerySearch(keyword);
epicSearchButton.Enabled = true;
generateButton.Enabled = true;
if (results.Count == 0)
{
SetStatus("No results found. Try a different name.");
return;
}
epicSearchResults.AddRange(results);
foreach ((string _, string name) in results)
epicResultsListBox.Items.Add(name);
epicResultsListBox.Visible = true;
SetStatus($"Found {results.Count} result(s). Select one to use it.");
}
private void OnEpicResultSelected(object sender, EventArgs e)
{
int idx = epicResultsListBox.SelectedIndex;
if (idx < 0 || idx >= epicSearchResults.Count)
return;
gameNameTextBox.Text = epicSearchResults[idx].name;
SetStatus($"✓ Selected: {epicSearchResults[idx].name}");
}
// ── DLC (Steam) ──────────────────────────────────────────────────────────
private void OnAddDlc(object sender, EventArgs e)
{
string dlcId = dlcIdTextBox.Text.Trim();
string dlcName = dlcNameTextBox.Text.Trim();
if (string.IsNullOrWhiteSpace(dlcId) || !int.TryParse(dlcId, out _))
{
SetStatus("DLC ID must be a valid integer.");
return;
}
if (string.IsNullOrWhiteSpace(dlcName))
{
SetStatus("DLC Name cannot be empty.");
return;
}
if (dlcEntries.Any(d => d.id == dlcId))
{
SetStatus($"DLC ID {dlcId} is already in the list.");
return;
}
dlcEntries.Add((dlcId, dlcName));
RefreshDlcList();
dlcIdTextBox.Clear();
dlcNameTextBox.Clear();
SetStatus($"Added DLC: {dlcId} = {dlcName}");
}
private void OnRemoveDlc(object sender, EventArgs e)
{
if (dlcListBox.SelectedIndex < 0)
return;
dlcEntries.RemoveAt(dlcListBox.SelectedIndex);
RefreshDlcList();
SetStatus("Removed selected DLC entry.");
}
private void OnDlcListBoxSelectionChanged(object sender, EventArgs e) { }
private void RefreshDlcList()
{
dlcListBox.Items.Clear();
foreach ((string id, string name) in dlcEntries)
dlcListBox.Items.Add($"{id} = {name}");
}
// ── Generate ────────────────────────────────────────────────────────────
private void OnGenerate(object sender, EventArgs e)
{
if (IsEpicMode)
GenerateEpic();
else
GenerateSteam();
}
private void GenerateSteam()
{
string appId = appIdTextBox.Text.Trim();
string gameName = gameNameTextBox.Text.Trim();
if (string.IsNullOrWhiteSpace(appId) || !int.TryParse(appId, out _))
{
SetStatus("App ID must be a valid integer.");
return;
}
if (string.IsNullOrWhiteSpace(gameName))
{
SetStatus("Game Name cannot be empty.");
return;
}
if (SteamLibrary.TestGames.Any(g => g.appId == appId))
{
SetStatus($"A test game with App ID {appId} already exists.");
return;
}
try
{
string gameDir = Path.Combine(TestGamesRoot, $"steam_{appId}_{SanitizeName(gameName)}");
Directory.CreateDirectory(gameDir);
string dllPath = Path.Combine(gameDir, "steam_api64.dll");
WriteSteamApiStub(dllPath);
CreatedDirectories.Add(gameDir);
SteamLibrary.TestGames.Add((appId, gameName, "public", 1, gameDir));
ProgramData.Log($"[TestGame] Steam: {gameName} ({appId}) at {gameDir}");
SetStatus($"✓ Steam test game '{gameName}' ({appId}) generated. Press Rescan.");
}
catch (Exception ex)
{
SetStatus($"Error: {ex.Message}");
}
}
private void GenerateEpic()
{
string gameName = gameNameTextBox.Text.Trim();
if (string.IsNullOrWhiteSpace(gameName))
{
SetStatus("Game Name cannot be empty. Search for a game first.");
return;
}
// Use the selected search result namespace if available, otherwise derive a stub
string catalogNamespace;
int idx = epicResultsListBox.SelectedIndex;
if (idx >= 0 && idx < epicSearchResults.Count)
{
catalogNamespace = epicSearchResults[idx].ns;
gameName = epicSearchResults[idx].name;
}
else
{
catalogNamespace = $"test_{SanitizeName(gameName).ToLowerInvariant()}";
}
if (EpicLibrary.TestManifests.Any(m => m.CatalogNamespace == catalogNamespace))
{
SetStatus("An Epic test game with that namespace already exists.");
return;
}
try
{
string gameDir = Path.Combine(TestGamesRoot, $"epic_{SanitizeName(gameName)}");
Directory.CreateDirectory(gameDir);
// Stub DLL so Epic DLL-directory scanning finds the game
string dllPath = Path.Combine(gameDir, "EOSSDK-Win64-Shipping.dll");
WriteSteamApiStub(dllPath);
CreatedDirectories.Add(gameDir);
EpicLibrary.TestManifests.Add(new Manifest
{
DisplayName = gameName,
CatalogNamespace = catalogNamespace,
InstallLocation = gameDir
});
ProgramData.Log($"[TestGame] Epic: {gameName} ({catalogNamespace}) at {gameDir}");
SetStatus($"✓ Epic test game '{gameName}' generated. Press Rescan.");
}
catch (Exception ex)
{
SetStatus($"Error: {ex.Message}");
}
}
// ── Clear / Close ────────────────────────────────────────────────────────
private void OnClearAll(object sender, EventArgs e)
{
SteamLibrary.TestGames.Clear();
EpicLibrary.TestManifests.Clear();
foreach (string dir in CreatedDirectories)
try { Directory.Delete(dir, true); } catch { /* best-effort */ }
CreatedDirectories.Clear();
dlcEntries.Clear();
RefreshDlcList();
epicSearchResults.Clear();
epicResultsListBox.Items.Clear();
epicResultsListBox.Visible = false;
SetStatus("All test games cleared. Press Rescan in the main window.");
}
private void OnClose(object sender, EventArgs e) => Close();
// ── Helpers ──────────────────────────────────────────────────────────────
private void SetStatus(string message)
{
statusLabel.Text = message;
statusLabel.ForeColor = message.StartsWith("✓", StringComparison.Ordinal)
? System.Drawing.Color.Green
: System.Drawing.Color.FromArgb(212, 212, 212);
}
private static string SanitizeName(string name)
{
char[] invalid = Path.GetInvalidFileNameChars();
return new string(name.Select(c => invalid.Contains(c) ? '_' : c).ToArray());
}
private static void WriteSteamApiStub(string path)
{
byte[] mzStub =
[
0x4D, 0x5A,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
];
File.WriteAllBytes(path, mzStub);
}
}
+120
View File
@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>
+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)
@@ -28,10 +28,17 @@ internal static class EpicLibrary
}
}
internal static readonly List<Manifest> TestManifests = [];
internal static async Task<List<Manifest>> GetGames()
=> await Task.Run(async () =>
{
List<Manifest> games = new();
foreach (Manifest test in TestManifests)
if (games.All(g => g.CatalogNamespace != test.CatalogNamespace))
games.Add(test);
string manifests = EpicManifestsPath;
if (manifests.DirectoryExists())
foreach (string item in manifests.EnumerateDirectory("*.item"))
@@ -132,6 +132,53 @@ internal static class EpicStore
public static bool EpicBool = true;
internal static async Task<List<(string @namespace, string name)>> QuerySearch(string keyword)
{
List<(string, string)> results = [];
try
{
string query = """
query searchByKeyword($keywords: String!) {
Catalog {
searchStore(keywords: $keywords, category: "games/edition/base", count: 10, country: "US", locale: "en-US", allowCountries: "US") {
elements {
title
namespace
}
}
}
}
""";
var payload = new { query, variables = new { keywords = keyword } };
string payloadJson = JsonConvert.SerializeObject(payload);
using HttpContent content = new StringContent(payloadJson, System.Text.Encoding.UTF8, "application/json");
HttpClient client = HttpClientManager.HttpClient;
if (client is null)
return results;
HttpResponseMessage httpResponse =
await client.PostAsync(new Uri("https://launcher.store.epicgames.com/graphql"), content);
_ = httpResponse.EnsureSuccessStatusCode();
string response = await httpResponse.Content.ReadAsStringAsync();
Newtonsoft.Json.Linq.JObject root = Newtonsoft.Json.Linq.JObject.Parse(response);
Newtonsoft.Json.Linq.JToken elements = root["data"]?["Catalog"]?["searchStore"]?["elements"];
if (elements is null)
return results;
foreach (Newtonsoft.Json.Linq.JToken el in elements)
{
string name = el["title"]?.ToString();
string ns = el["namespace"]?.ToString();
if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(ns)
&& results.All(r => r.Item1 != ns))
results.Add((ns, name));
}
}
catch
{
// ignored
}
return results;
}
private static async Task<Response> QueryGraphQL(string categoryNamespace)
{
try
+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())
+67 -8
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using CreamInstaller.Utility;
@@ -9,6 +10,9 @@ namespace CreamInstaller.Platforms.Steam;
internal static class SteamLibrary
{
internal static readonly List<(string appId, string name, string branch, int buildId, string gameDirectory)>
TestGames = [];
private static string installPath;
internal static string InstallPath
@@ -28,16 +32,24 @@ internal static class SteamLibrary
{
List<(string appId, string name, string branch, int buildId, string gameDirectory)> games = new();
HashSet<string> gameLibraryDirectories = await GetLibraryDirectories();
ProgramData.Log($"[Steam] Found {gameLibraryDirectories.Count} library folder(s).");
foreach (string libraryDirectory in gameLibraryDirectories)
{
if (Program.Canceled)
return games;
ProgramData.Log($"[Steam] Scanning library: {libraryDirectory}");
foreach ((string appId, string name, string branch, int buildId, string gameDirectory) game in (await
GetGamesFromLibraryDirectory(
libraryDirectory)).Where(game => games.All(_game => _game.appId != game.appId)))
games.Add(game);
}
foreach ((string appId, string name, string branch, int buildId, string gameDirectory) testGame in
TestGames.Where(t => games.All(g => g.appId != t.appId)))
games.Add(testGame);
if (TestGames.Count > 0)
ProgramData.Log($"[Steam] Injected {TestGames.Count} test game(s).");
ProgramData.Log($"[Steam] Total games detected: {games.Count}");
return games;
});
@@ -47,13 +59,21 @@ internal static class SteamLibrary
{
List<(string appId, string name, string branch, int buildId, string gameDirectory)> games = new();
if (Program.Canceled || !libraryDirectory.DirectoryExists())
{
ProgramData.Log($"[Steam] Skipping library (not found or canceled): {libraryDirectory}");
return games;
}
foreach (string file in libraryDirectory.EnumerateDirectory("*.acf"))
{
if (Program.Canceled)
return games;
if (!ValveDataFile.TryDeserialize(file.ReadFile(), out VProperty result))
{
ProgramData.Log($"[Steam] Failed to deserialize ACF: {file}");
continue;
}
string appId = result.Value.GetChild("appid")?.ToString();
string installdir = result.Value.GetChild("installdir")?.ToString();
string name = result.Value.GetChild("name")?.ToString();
@@ -61,11 +81,23 @@ internal static class SteamLibrary
if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(installdir) ||
string.IsNullOrWhiteSpace(name)
|| string.IsNullOrWhiteSpace(buildId))
{
ProgramData.Log($"[Steam] Skipping ACF with missing fields: {file}");
continue;
string gameDirectory = (libraryDirectory + @"\common\" + installdir).ResolvePath();
if (gameDirectory is null || !int.TryParse(appId, out int _) ||
!int.TryParse(buildId, out int buildIdInt) || games.Any(g => g.appId == appId))
}
string rawGameDirectory = libraryDirectory + @"\common\" + installdir;
string gameDirectory = rawGameDirectory.ResolvePath();
if (gameDirectory is null)
{
ProgramData.Log($"[Steam] Game directory not found (drive may be slow or disconnected): {rawGameDirectory} | App: {name} ({appId})");
continue;
}
if (!int.TryParse(appId, out int _) || !int.TryParse(buildId, out int buildIdInt) ||
games.Any(g => g.appId == appId))
continue;
VToken userConfig = result.Value.GetChild("UserConfig");
string branch = userConfig?.GetChild("BetaKey")?.ToString();
branch ??= userConfig?.GetChild("betakey")?.ToString();
@@ -78,6 +110,8 @@ internal static class SteamLibrary
if (string.IsNullOrWhiteSpace(branch))
branch = "public";
ProgramData.Log($"[Steam] Detected game: {name} ({appId}) | Branch: {branch} | Dir: {gameDirectory}");
games.Add((appId, name, branch, buildIdInt, gameDirectory));
}
@@ -92,25 +126,50 @@ internal static class SteamLibrary
return libraryDirectories;
string steamInstallPath = InstallPath;
if (steamInstallPath == null || !steamInstallPath.DirectoryExists())
{
ProgramData.Log($"[Steam] Steam install path not found or inaccessible: {steamInstallPath ?? "(null)"}");
return libraryDirectories;
}
string libraryFolder = steamInstallPath + @"\steamapps";
if (!libraryFolder.DirectoryExists())
{
ProgramData.Log($"[Steam] Default steamapps folder not found: {libraryFolder}");
return libraryDirectories;
}
_ = libraryDirectories.Add(libraryFolder);
ProgramData.Log($"[Steam] Default library folder: {libraryFolder}");
string libraryFolders = libraryFolder + @"\libraryfolders.vdf";
if (!libraryFolders.FileExists() ||
!ValveDataFile.TryDeserialize(libraryFolders.ReadFile(), out VProperty result))
{
ProgramData.Log($"[Steam] libraryfolders.vdf not found or failed to parse: {libraryFolders}");
return libraryDirectories;
}
foreach (VToken vToken in result.Value.Where(p =>
p is VProperty property && int.TryParse(property.Key, out int _)))
{
VProperty property = (VProperty)vToken;
string path = property.Value.GetChild("path")?.ToString();
if (string.IsNullOrWhiteSpace(path))
string rawPath = property.Value.GetChild("path")?.ToString();
if (string.IsNullOrWhiteSpace(rawPath))
continue;
path += @"\steamapps";
if (path.DirectoryExists())
_ = libraryDirectories.Add(path);
// Normalize the path from VDF (may use forward slashes or wrong casing)
string normalizedPath = Path.GetFullPath(rawPath);
string steamappsPath = normalizedPath + @"\steamapps";
string resolvedPath = steamappsPath.ResolvePath();
if (resolvedPath is null)
{
ProgramData.Log($"[Steam] External library not accessible (drive may be disconnected or letter changed): {steamappsPath}");
continue;
}
if (libraryDirectories.Add(resolvedPath))
ProgramData.Log($"[Steam] Additional library folder found: {resolvedPath}");
}
return libraryDirectories;
+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);
}
+11 -2
View File
@@ -54,7 +54,16 @@ internal static class Diagnostics
if (info.Parent is null)
return info.Name.ToUpperInvariant();
string parent = ResolvePath(info.Parent.FullName);
string name = info.Parent.GetFileSystemInfos(info.Name)[0].Name;
return parent is null ? name : Path.Combine(parent, name);
try
{
FileSystemInfo[] infos = info.Parent.GetFileSystemInfos(info.Name);
string name = infos.Length > 0 ? infos[0].Name : info.Name;
return parent is null ? name : Path.Combine(parent, name);
}
catch
{
// Fall back to the raw name if the filesystem call fails (e.g. on a slow external drive)
return parent is null ? info.Name : Path.Combine(parent, info.Name);
}
}
}
+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;
}
}
}
+149
View File
@@ -1,13 +1,47 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
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 =
@@ -27,6 +61,40 @@ 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";
private static readonly object LogLock = new();
internal static void Log(string message)
{
try
{
string timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
string entry = $"[{timestamp}] {message}{Environment.NewLine}";
lock (LogLock)
File.AppendAllText(LogPath, entry, Encoding.UTF8);
}
catch
{
// ignored — logging must never crash the application
}
}
internal static void ClearLog()
{
try
{
if (File.Exists(LogPath))
File.Delete(LogPath);
}
catch
{
// ignored
}
}
internal static async Task Setup(Form form = null)
=> await Task.Run(() =>
@@ -195,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);
}
}
+179 -4
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)
// ----------------------------
@@ -43,7 +54,7 @@ internal static class ThemeManager
private static readonly Color LightPlatform = ColorTranslator.FromHtml("#696900");
private static readonly Color LightId = ColorTranslator.FromHtml("#006969");
private static readonly Color LightProxy = ColorTranslator.FromHtml("#006900");
private static readonly Color LightSelectionBack = SystemColors.Highlight;
private static readonly Color LightSelectionBack = ColorTranslator.FromHtml("#ADD6FF");
private static readonly Color LightComboBack = SystemColors.Control;
private static readonly Color LightComboBorder = SystemColors.ControlDark;
private static readonly Color LightComboText = SystemColors.ControlText;
@@ -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
// -----------------------------------------------------------------
@@ -180,9 +202,9 @@ internal static class ThemeManager
ll.VisitedLinkColor = DarkLink;
break;
// Labels: dark background, light foreground
// Labels: transparent so they blend with whatever container they sit in
case Label lbl:
lbl.BackColor = DarkBack;
lbl.BackColor = Color.Transparent;
lbl.ForeColor = DarkFore;
break;
@@ -206,6 +228,20 @@ internal static class ThemeManager
rtb.ForeColor = DarkFore;
break;
// ListBox follows alternate dark background
case ListBox lb:
lb.BackColor = DarkBackAlt;
lb.ForeColor = DarkFore;
break;
// TextBox follows alternate dark background
case TextBox tb:
tb.BackColor = DarkBackAlt;
tb.ForeColor = DarkFore;
tb.BorderStyle = BorderStyle.FixedSingle;
NativeMethods.RefreshCueBanner(tb);
break;
// Layout panels set a consistent background
case TableLayoutPanel tlp:
tlp.BackColor = DarkBack;
@@ -241,7 +277,7 @@ internal static class ThemeManager
ll.VisitedLinkColor = SystemColors.HotTrack;
break;
case Label lbl:
lbl.BackColor = LightBack;
lbl.BackColor = Color.Transparent;
lbl.ForeColor = LightFore;
break;
case ProgressBar pb:
@@ -258,6 +294,16 @@ internal static class ThemeManager
rtb.BackColor = LightBack;
rtb.ForeColor = LightFore;
break;
case ListBox lb:
lb.BackColor = LightBackAlt;
lb.ForeColor = LightFore;
break;
case TextBox tb:
tb.BackColor = LightBackAlt;
tb.ForeColor = LightFore;
tb.BorderStyle = BorderStyle.Fixed3D;
NativeMethods.RefreshCueBanner(tb);
break;
case TableLayoutPanel tlp:
tlp.BackColor = LightBack;
break;
@@ -408,6 +454,96 @@ internal static class ThemeManager
// button is centralized here so theming resides in ThemeManager.
// -----------------------------------------------------------------
// Dark checkbox colors matched to how the system renders the "All" CheckBox control
// in dark mode: dark fill, mid-gray border, light foreground tick.
private static readonly Color DarkCbBorder = ColorTranslator.FromHtml("#6B6B6B");
private static readonly Color DarkCbDisabledBorder = ColorTranslator.FromHtml("#454545");
/// <summary>
/// Draws a checkbox glyph in pure GDI that matches the appearance of a dark-themed
/// WinForms CheckBox control (same background, border, tick colors, and rounded corners).
/// Use this in owner-draw contexts where CheckBoxRenderer always paints a white background.
/// </summary>
internal static void DrawDarkCheckBox(Graphics g, Point point, Size glyphSize, bool isChecked, bool enabled = true)
{
if (g is null) return;
int w = glyphSize.Width;
int h = glyphSize.Height;
Rectangle box = new(point.X, point.Y, w - 1, h - 1);
int radius = Math.Max(2, w / 5);
using System.Drawing.Drawing2D.GraphicsPath path = RoundedRect(box, radius);
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
if (isChecked && enabled)
{
// Checked + enabled: accent fill, no border, white tick — matches Windows 11 dark CheckBox
using SolidBrush fillBrush = new(Accent);
g.FillPath(fillBrush, path);
using Pen tickPen = new(Color.White, 1.7f)
{
StartCap = System.Drawing.Drawing2D.LineCap.Round,
EndCap = System.Drawing.Drawing2D.LineCap.Round,
LineJoin = System.Drawing.Drawing2D.LineJoin.Round,
};
float scaleX = w / 13f;
float scaleY = h / 13f;
g.DrawLines(tickPen, new PointF[]
{
new(point.X + 2 * scaleX, point.Y + 6 * scaleY),
new(point.X + 5 * scaleX, point.Y + 9 * scaleY),
new(point.X + 10 * scaleX, point.Y + 3 * scaleY),
});
}
else if (isChecked)
{
// Checked + disabled: dimmed accent fill, dimmed tick
Color dimAccent = Color.FromArgb(120, Accent);
using SolidBrush fillBrush = new(dimAccent);
g.FillPath(fillBrush, path);
using Pen tickPen = new(DarkForeDim, 1.7f)
{
StartCap = System.Drawing.Drawing2D.LineCap.Round,
EndCap = System.Drawing.Drawing2D.LineCap.Round,
LineJoin = System.Drawing.Drawing2D.LineJoin.Round,
};
float scaleX = w / 13f;
float scaleY = h / 13f;
g.DrawLines(tickPen, new PointF[]
{
new(point.X + 2 * scaleX, point.Y + 6 * scaleY),
new(point.X + 5 * scaleX, point.Y + 9 * scaleY),
new(point.X + 10 * scaleX, point.Y + 3 * scaleY),
});
}
else
{
// Unchecked: dark fill, gray border, no tick
using SolidBrush fillBrush = new(DarkBackAlt);
g.FillPath(fillBrush, path);
using Pen borderPen = new(enabled ? DarkCbBorder : DarkCbDisabledBorder);
g.DrawPath(borderPen, path);
}
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.Default;
}
private static System.Drawing.Drawing2D.GraphicsPath RoundedRect(Rectangle r, int radius)
{
int d = radius * 2;
System.Drawing.Drawing2D.GraphicsPath path = new();
path.AddArc(r.Left, r.Top, d, d, 180, 90);
path.AddArc(r.Right - d, r.Top, d, d, 270, 90);
path.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90);
path.AddArc(r.Left, r.Bottom - d, d, d, 90, 90);
path.CloseFigure();
return path;
}
/// <summary>
/// Draws the themed combobox area (background, border and text) used in CustomTreeView.
/// This centralizes colors and rendering for light/dark modes.
@@ -450,15 +586,54 @@ internal static class ThemeManager
}
}
/// <summary>
/// Wraps Win32 API calls that have no managed equivalent in WinForms.
/// These P/Invoke declarations are required because .NET does not expose
/// the underlying Windows messages or DWM attributes through its own APIs.
/// </summary>
internal static class NativeMethods
{
// DWM attribute index for enabling/disabling the immersive dark title bar.
// Documented in dwmapi.h; value 20 corresponds to DWMWA_USE_IMMERSIVE_DARK_MODE
// (Windows 10 build 19041+ / Windows 11).
private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20;
// DwmSetWindowAttribute allows setting per-window Desktop Window Manager attributes.
// We use it here to flip the title bar to dark or light depending on the active theme,
// since WinForms has no built-in API to control title bar coloring.
[System.Runtime.InteropServices.DllImport("dwmapi.dll")]
private static extern int DwmSetWindowAttribute(System.IntPtr hwnd, int attr, ref int attrValue, int attrSize);
/// <summary>
/// Toggles the dark/light title bar chrome for the given window handle.
/// Pass <c>1</c> for dark mode, <c>0</c> for light mode.
/// </summary>
internal static void EnableDarkTitleBar(System.IntPtr handle, int useDark)
{
_ = DwmSetWindowAttribute(handle, DWMWA_USE_IMMERSIVE_DARK_MODE, ref useDark, sizeof(int));
}
// Win32 Edit control message that sets or updates the cue (placeholder) banner text.
// WinForms sets PlaceholderText once at creation time via this same message internally,
// but does not re-send it when the control's colors change. When we restyle a TextBox
// for dark/light mode the cue banner can disappear, so we must re-send the message
// manually to make the placeholder visible again.
private const int EM_SETCUEBANNER = 0x1501;
// SendMessage is the standard Win32 mechanism for posting messages directly to a
// window/control handle. We use the Unicode variant so the placeholder string is
// transmitted without any ANSI conversion.
[System.Runtime.InteropServices.DllImport("user32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
private static extern System.IntPtr SendMessage(System.IntPtr hWnd, int msg, System.IntPtr wParam, string lParam);
/// <summary>
/// Re-sends <c>EM_SETCUEBANNER</c> to the given TextBox so its placeholder text
/// is redrawn after a theme change has altered the control's background or foreground colors.
/// Does nothing if the control handle has not yet been created or the placeholder is empty.
/// </summary>
internal static void RefreshCueBanner(System.Windows.Forms.TextBox textBox)
{
if (textBox?.IsHandleCreated == true && textBox.PlaceholderText is { Length: > 0 })
SendMessage(textBox.Handle, EM_SETCUEBANNER, (System.IntPtr)1, textBox.PlaceholderText);
}
}
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 39 KiB