mirror of
https://github.com/FroggMaster/CreamInstaller.git
synced 2026-06-12 19:11:25 -07:00
Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb075a32db | |||
| 30bd1035b2 | |||
| 0dbd35ed0c | |||
| 668463f687 | |||
| 69d29d6863 | |||
| 94bec38bd0 | |||
| 66cf72faeb | |||
| 54592230c3 | |||
| 34cb3b862c | |||
| 8040e6bcdb | |||
| 593f396c54 | |||
| 2f993bfe3b | |||
| e9f8222d8e | |||
| 68842aad9f |
@@ -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();
|
||||
@@ -132,10 +135,62 @@ internal sealed class CustomTreeView : TreeView
|
||||
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
|
||||
{
|
||||
e.DrawDefault = true;
|
||||
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;
|
||||
@@ -202,6 +257,93 @@ internal sealed class CustomTreeView : TreeView
|
||||
graphics.FillRectangle(brush, bounds);
|
||||
}
|
||||
|
||||
// Unlocker badge
|
||||
if (selection.InstalledUnlocker != InstalledUnlocker.None)
|
||||
{
|
||||
string badgeText = selection.InstalledUnlocker.ToString();
|
||||
size = TextRenderer.MeasureText(graphics, badgeText, font, Size.Empty, TextFormatFlags.NoPadding);
|
||||
const int badgePadding = 3;
|
||||
Rectangle badgeBounds = new(bounds.X + bounds.Width + 2, bounds.Y + 1, size.Width + badgePadding * 2, bounds.Height - 2);
|
||||
selectionBounds = new(selectionBounds.Location, selectionBounds.Size + new Size(badgeBounds.Width + 2, 0));
|
||||
|
||||
// Get theme-appropriate colors for each unlocker from ThemeManager
|
||||
Color badgeBack, badgeBorder;
|
||||
switch (selection.InstalledUnlocker)
|
||||
{
|
||||
case InstalledUnlocker.SmokeAPI:
|
||||
badgeBack = highlighted
|
||||
? ThemeManager.SmokeAPIBadgeBackgroundHighlightColor
|
||||
: ThemeManager.SmokeAPIBadgeBackgroundColor;
|
||||
badgeBorder = ThemeManager.SmokeAPIBadgeBorderColor;
|
||||
break;
|
||||
case InstalledUnlocker.CreamAPI:
|
||||
badgeBack = highlighted
|
||||
? ThemeManager.CreamAPIBadgeBackgroundHighlightColor
|
||||
: ThemeManager.CreamAPIBadgeBackgroundColor;
|
||||
badgeBorder = ThemeManager.CreamAPIBadgeBorderColor;
|
||||
break;
|
||||
default:
|
||||
badgeBack = highlighted
|
||||
? ThemeManager.DefaultBadgeBackgroundHighlightColor
|
||||
: ThemeManager.DefaultBadgeBackgroundColor;
|
||||
badgeBorder = ThemeManager.DefaultBadgeBorderColor;
|
||||
break;
|
||||
}
|
||||
|
||||
using (SolidBrush badgeBrush = new(badgeBack))
|
||||
graphics.FillRectangle(badgeBrush, badgeBounds);
|
||||
using (Pen badgePen = new(badgeBorder))
|
||||
graphics.DrawRectangle(badgePen, badgeBounds);
|
||||
TextRenderer.DrawText(graphics, badgeText, font,
|
||||
new Point(badgeBounds.X + badgePadding, badgeBounds.Y + 1),
|
||||
Color.White, TextFormatFlags.NoPadding);
|
||||
bounds = bounds with { X = badgeBounds.X, Width = badgeBounds.Width + 2 };
|
||||
}
|
||||
|
||||
// Show Extra Protection checkbox for CreamAPI:
|
||||
// - When CreamAPI is installed, OR
|
||||
// - When no unlocker is installed yet AND user hasn't enabled SmokeAPI mode, OR
|
||||
// - When SmokeAPI is installed BUT user has disabled SmokeAPI mode (about to replace with CreamAPI)
|
||||
bool showExtraProtection = selection.InstalledUnlocker == InstalledUnlocker.CreamAPI ||
|
||||
(selection.InstalledUnlocker == InstalledUnlocker.None && !Program.UseSmokeAPI) ||
|
||||
(selection.InstalledUnlocker == InstalledUnlocker.SmokeAPI && !Program.UseSmokeAPI);
|
||||
|
||||
if (showExtraProtection)
|
||||
{
|
||||
CheckBoxState extraProtState = selection.UseExtraProtection
|
||||
? (Enabled ? CheckBoxState.CheckedNormal : CheckBoxState.CheckedDisabled)
|
||||
: (Enabled ? CheckBoxState.UncheckedNormal : CheckBoxState.UncheckedDisabled);
|
||||
size = CheckBoxRenderer.GetGlyphSize(graphics, extraProtState);
|
||||
bounds = bounds with { X = bounds.X + bounds.Width, Width = size.Width };
|
||||
selectionBounds = new(selectionBounds.Location, selectionBounds.Size + bounds.Size with { Height = 0 });
|
||||
Rectangle extraProtCheckBoxBounds = bounds;
|
||||
graphics.FillRectangle(brush, bounds);
|
||||
point = new(bounds.Left, bounds.Top + bounds.Height / 2 - size.Height / 2 - 1);
|
||||
if (dark)
|
||||
ThemeManager.DrawDarkCheckBox(graphics, point, size, selection.UseExtraProtection, Enabled);
|
||||
else
|
||||
CheckBoxRenderer.DrawCheckBox(graphics, point, extraProtState);
|
||||
|
||||
text = ExtraProtectionToggleString;
|
||||
size = TextRenderer.MeasureText(graphics, text, font);
|
||||
int leftEP = 1;
|
||||
bounds = bounds with { X = bounds.X + bounds.Width, Width = size.Width + leftEP };
|
||||
selectionBounds = new(selectionBounds.Location, selectionBounds.Size + bounds.Size with { Height = 0 });
|
||||
extraProtCheckBoxBounds = new(extraProtCheckBoxBounds.Location, extraProtCheckBoxBounds.Size + bounds.Size with { Height = 0 });
|
||||
graphics.FillRectangle(brush, bounds);
|
||||
point = new(bounds.Location.X - 1 + leftEP, bounds.Location.Y + 1);
|
||||
TextRenderer.DrawText(graphics, text, font, point,
|
||||
Enabled ? ThemeManager.CustomTreeViewProxyColor : ThemeManager.CustomTreeViewDisabledProxyColor,
|
||||
TextFormatFlags.Default);
|
||||
|
||||
extraProtectionCheckBoxBounds[selection] = RectangleToClient(extraProtCheckBoxBounds);
|
||||
|
||||
// Add spacing before proxy checkbox
|
||||
size = new(4, 0);
|
||||
bounds = bounds with { X = bounds.X + bounds.Width, Width = size.Width };
|
||||
graphics.FillRectangle(brush, bounds);
|
||||
}
|
||||
|
||||
CheckBoxState proxyState = selection.UseProxy
|
||||
? (Enabled ? CheckBoxState.CheckedNormal : CheckBoxState.CheckedDisabled)
|
||||
: (Enabled ? CheckBoxState.UncheckedNormal : CheckBoxState.UncheckedDisabled);
|
||||
@@ -209,7 +351,7 @@ internal sealed class CustomTreeView : TreeView
|
||||
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);
|
||||
if (dark)
|
||||
ThemeManager.DrawDarkCheckBox(graphics, point, size, selection.UseProxy, Enabled);
|
||||
@@ -222,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,
|
||||
@@ -347,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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
<TargetFramework>net8.0-windows10.0.22621.0</TargetFramework>
|
||||
<UseWindowsForms>True</UseWindowsForms>
|
||||
<ApplicationIcon>Resources\program.ico</ApplicationIcon>
|
||||
<Version>5.0.2.1</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 & Configuration Generator</Product>
|
||||
<StartupObject>CreamInstaller.Program</StartupObject>
|
||||
|
||||
+20
-3
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -68,10 +68,10 @@ internal static class UplayR1
|
||||
false);
|
||||
}
|
||||
|
||||
writer.WriteLine(" ],");
|
||||
writer.WriteLine(" ]");
|
||||
}
|
||||
else
|
||||
writer.WriteLine(" \"blacklist\": [],");
|
||||
writer.WriteLine(" \"blacklist\": []");
|
||||
|
||||
writer.WriteLine("}");
|
||||
}
|
||||
|
||||
@@ -72,10 +72,10 @@ internal static class UplayR2
|
||||
false);
|
||||
}
|
||||
|
||||
writer.WriteLine(" ],");
|
||||
writer.WriteLine(" ]");
|
||||
}
|
||||
else
|
||||
writer.WriteLine(" \"blacklist\": [],");
|
||||
writer.WriteLine(" \"blacklist\": []");
|
||||
|
||||
writer.WriteLine("}");
|
||||
}
|
||||
|
||||
+100
-1
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,12 @@ 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;
|
||||
@@ -249,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:
|
||||
@@ -266,6 +294,10 @@ 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;
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 39 KiB |
Reference in New Issue
Block a user