mirror of
https://github.com/FroggMaster/CreamInstaller.git
synced 2026-06-12 19:11:25 -07:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eb075a32db | |||
| 30bd1035b2 | |||
| 0dbd35ed0c | |||
| 668463f687 | |||
| 69d29d6863 | |||
| 94bec38bd0 | |||
| 66cf72faeb | |||
| 54592230c3 | |||
| 34cb3b862c | |||
| 8040e6bcdb | |||
| 593f396c54 | |||
| 2f993bfe3b | |||
| e9f8222d8e | |||
| 68842aad9f | |||
| 1d5dc4ac8c | |||
| 31ca8a947f | |||
| 558612f098 | |||
| b7067c2621 | |||
| fe55efc072 | |||
| 39097c27ef | |||
| 3ba4747be3 | |||
| 322490d0b2 | |||
| 3dae7508f0 | |||
| 8f8e893e84 | |||
| 0cec730c1e | |||
| df7dc0e019 |
@@ -62,6 +62,7 @@ jobs:
|
||||
tag_name: v${{ inputs.version }}
|
||||
name: ${{ inputs.title || format('Release v{0}', inputs.version) }}
|
||||
body: ${{ inputs.notes }}
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
./publish/CreamInstaller.exe
|
||||
./publish/CreamInstaller.zip
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Dev CI Builds
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- dev
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '8.0.x'
|
||||
|
||||
- name: Restore dependencies
|
||||
run: dotnet restore CreamInstaller.sln
|
||||
|
||||
- name: Build Release
|
||||
run: dotnet build CreamInstaller.sln --configuration Release --no-restore
|
||||
|
||||
- name: Publish single-file
|
||||
run: dotnet publish CreamInstaller.sln -c Release -r win-x64 -p:PublishSingleFile=true --self-contained true --output ./publish
|
||||
|
||||
- name: Set short commit SHA and branch name
|
||||
id: vars
|
||||
run: |
|
||||
$shortSha = $env:GITHUB_SHA.Substring(0,7)
|
||||
$branch = $env:GITHUB_REF_NAME
|
||||
Write-Output "shortSha=$shortSha" >> $env:GITHUB_ENV
|
||||
Write-Output "branch=$branch" >> $env:GITHUB_ENV
|
||||
shell: pwsh
|
||||
|
||||
- name: Rename EXE with branch and short commit SHA
|
||||
run: |
|
||||
Rename-Item -Path ./publish/CreamInstaller.exe -NewName "CreamInstaller-CI-$env:branch-$env:shortSha.exe"
|
||||
shell: pwsh
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: CreamInstaller-CI-${{ env.branch }}-${{ env.shortSha }}
|
||||
path: ./publish/CreamInstaller-CI-${{ env.branch }}-${{ env.shortSha }}.exe
|
||||
@@ -14,8 +14,10 @@ namespace CreamInstaller.Components;
|
||||
internal sealed class CustomTreeView : TreeView
|
||||
{
|
||||
private const string ProxyToggleString = "Proxy";
|
||||
private const string ExtraProtectionToggleString = "Extra Protection";
|
||||
|
||||
private readonly Dictionary<Selection, Rectangle> checkBoxBounds = [];
|
||||
private readonly Dictionary<Selection, Rectangle> extraProtectionCheckBoxBounds = [];
|
||||
private readonly Dictionary<Selection, Rectangle> comboBoxBounds = [];
|
||||
|
||||
private readonly Dictionary<TreeNode, Rectangle> selectionBounds = [];
|
||||
@@ -62,6 +64,7 @@ internal sealed class CustomTreeView : TreeView
|
||||
private void OnInvalidated(object sender, EventArgs e)
|
||||
{
|
||||
checkBoxBounds.Clear();
|
||||
extraProtectionCheckBoxBounds.Clear();
|
||||
comboBoxBounds.Clear();
|
||||
selectionBounds.Clear();
|
||||
backBrush?.Dispose();
|
||||
@@ -75,11 +78,14 @@ internal sealed class CustomTreeView : TreeView
|
||||
|
||||
private void DrawTreeNode(object sender, DrawTreeNodeEventArgs e)
|
||||
{
|
||||
e.DrawDefault = true;
|
||||
TreeNode node = e.Node;
|
||||
if (node is not { IsVisible: true })
|
||||
{
|
||||
e.DrawDefault = true;
|
||||
return;
|
||||
}
|
||||
|
||||
bool dark = Program.DarkModeEnabled;
|
||||
bool highlighted = (e.State & TreeNodeStates.Selected) == TreeNodeStates.Selected && Focused;
|
||||
Graphics graphics = e.Graphics;
|
||||
|
||||
@@ -103,12 +109,95 @@ internal sealed class CustomTreeView : TreeView
|
||||
}
|
||||
}
|
||||
|
||||
Form form = FindForm();
|
||||
|
||||
if (dark && CheckBoxes)
|
||||
{
|
||||
// In dark mode we take full ownership of the row so the system never
|
||||
// gets a chance to paint a light-background checkbox.
|
||||
e.DrawDefault = false;
|
||||
|
||||
// Row background
|
||||
Rectangle rowRect = new(0, node.Bounds.Top, ClientSize.Width, node.Bounds.Height);
|
||||
graphics.FillRectangle(highlighted ? selectionBrush : backBrush, rowRect);
|
||||
|
||||
// Node text
|
||||
Font nodeFont = node.NodeFont ?? Font;
|
||||
Color textColor = Enabled ? ForeColor : SystemColors.GrayText;
|
||||
TextRenderer.DrawText(graphics, node.Text, nodeFont,
|
||||
new Point(node.Bounds.Left, node.Bounds.Top + 1), textColor, TextFormatFlags.Default);
|
||||
|
||||
// Checkbox glyph – pure GDI so it matches the dark-themed CheckBox controls
|
||||
CheckBoxState cbState = node.Checked
|
||||
? (Enabled ? CheckBoxState.CheckedNormal : CheckBoxState.CheckedDisabled)
|
||||
: (Enabled ? CheckBoxState.UncheckedNormal : CheckBoxState.UncheckedDisabled);
|
||||
Size cbSize = CheckBoxRenderer.GetGlyphSize(graphics, cbState);
|
||||
int cbX = node.Bounds.Left - cbSize.Width - 2;
|
||||
int cbY = node.Bounds.Top + node.Bounds.Height / 2 - cbSize.Height / 2;
|
||||
ThemeManager.DrawDarkCheckBox(graphics, new Point(cbX, cbY), cbSize, node.Checked, Enabled);
|
||||
|
||||
// Expander glyph (expand/collapse) – the system skips this when DrawDefault=false
|
||||
if (node.Nodes.Count > 0)
|
||||
{
|
||||
int indent = Indent;
|
||||
int level = node.Level;
|
||||
int glyphSize = 13;
|
||||
int glyphX = level * indent + (indent - glyphSize) / 2 + (ShowRootLines ? 0 : -indent);
|
||||
int glyphY = node.Bounds.Top + node.Bounds.Height / 2 - glyphSize / 2;
|
||||
Rectangle glyphRect = new(glyphX, glyphY, glyphSize, glyphSize);
|
||||
Color glyphBorder = Color.FromArgb(0x6B, 0x6B, 0x6B);
|
||||
Color glyphBack = Color.FromArgb(0x2D, 0x2D, 0x2D);
|
||||
Color glyphFore = Color.FromArgb(0xD4, 0xD4, 0xD4);
|
||||
using (SolidBrush backFill = new(glyphBack))
|
||||
graphics.FillRectangle(backFill, glyphRect);
|
||||
using (Pen borderPen = new(glyphBorder))
|
||||
graphics.DrawRectangle(borderPen, glyphRect);
|
||||
int mid = glyphY + glyphSize / 2;
|
||||
int left = glyphX + 3;
|
||||
int right = glyphX + glyphSize - 3;
|
||||
using (Pen linePen = new(glyphFore))
|
||||
{
|
||||
graphics.DrawLine(linePen, left, mid, right, mid); // horizontal minus
|
||||
if (!node.IsExpanded)
|
||||
graphics.DrawLine(linePen, glyphX + glyphSize / 2, glyphY + 3, glyphX + glyphSize / 2, glyphY + glyphSize - 3); // vertical plus
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
if (highlighted && CheckBoxes)
|
||||
{
|
||||
// In light mode, take ownership of the row when selected so the
|
||||
// highlight fills the full width (same approach as dark mode).
|
||||
e.DrawDefault = false;
|
||||
|
||||
Rectangle rowRect = new(0, node.Bounds.Top, ClientSize.Width, node.Bounds.Height);
|
||||
graphics.FillRectangle(selectionBrush, rowRect);
|
||||
|
||||
Font nodeFont = node.NodeFont ?? Font;
|
||||
Color textColor = Enabled ? ForeColor : SystemColors.GrayText;
|
||||
TextRenderer.DrawText(graphics, node.Text, nodeFont,
|
||||
new Point(node.Bounds.Left, node.Bounds.Top + 1), textColor, TextFormatFlags.Default);
|
||||
|
||||
CheckBoxState cbState = node.Checked
|
||||
? (Enabled ? CheckBoxState.CheckedNormal : CheckBoxState.CheckedDisabled)
|
||||
: (Enabled ? CheckBoxState.UncheckedNormal : CheckBoxState.UncheckedDisabled);
|
||||
Size cbSize = CheckBoxRenderer.GetGlyphSize(graphics, cbState);
|
||||
Point cbPoint = new(node.Bounds.Left - cbSize.Width - 2,
|
||||
node.Bounds.Top + node.Bounds.Height / 2 - cbSize.Height / 2);
|
||||
CheckBoxRenderer.DrawCheckBox(graphics, cbPoint, cbState);
|
||||
}
|
||||
else
|
||||
{
|
||||
e.DrawDefault = true;
|
||||
}
|
||||
}
|
||||
|
||||
Font font = node.NodeFont ?? Font;
|
||||
Brush brush = highlighted ? (Brush)selectionBrush : backBrush;
|
||||
Rectangle bounds = node.Bounds;
|
||||
Rectangle selectionBounds = bounds;
|
||||
|
||||
Form form = FindForm();
|
||||
if (form is not SelectForm and not SelectDialogForm)
|
||||
return;
|
||||
|
||||
@@ -168,18 +257,106 @@ internal sealed class CustomTreeView : TreeView
|
||||
graphics.FillRectangle(brush, bounds);
|
||||
}
|
||||
|
||||
CheckBoxState checkBoxState = selection.UseProxy
|
||||
? Enabled ? CheckBoxState.CheckedPressed : CheckBoxState.CheckedDisabled
|
||||
: Enabled
|
||||
? CheckBoxState.UncheckedPressed
|
||||
: CheckBoxState.UncheckedDisabled;
|
||||
size = CheckBoxRenderer.GetGlyphSize(graphics, checkBoxState);
|
||||
// Unlocker badge
|
||||
if (selection.InstalledUnlocker != InstalledUnlocker.None)
|
||||
{
|
||||
string badgeText = selection.InstalledUnlocker.ToString();
|
||||
size = TextRenderer.MeasureText(graphics, badgeText, font, Size.Empty, TextFormatFlags.NoPadding);
|
||||
const int badgePadding = 3;
|
||||
Rectangle badgeBounds = new(bounds.X + bounds.Width + 2, bounds.Y + 1, size.Width + badgePadding * 2, bounds.Height - 2);
|
||||
selectionBounds = new(selectionBounds.Location, selectionBounds.Size + new Size(badgeBounds.Width + 2, 0));
|
||||
|
||||
// Get theme-appropriate colors for each unlocker from ThemeManager
|
||||
Color badgeBack, badgeBorder;
|
||||
switch (selection.InstalledUnlocker)
|
||||
{
|
||||
case InstalledUnlocker.SmokeAPI:
|
||||
badgeBack = highlighted
|
||||
? ThemeManager.SmokeAPIBadgeBackgroundHighlightColor
|
||||
: ThemeManager.SmokeAPIBadgeBackgroundColor;
|
||||
badgeBorder = ThemeManager.SmokeAPIBadgeBorderColor;
|
||||
break;
|
||||
case InstalledUnlocker.CreamAPI:
|
||||
badgeBack = highlighted
|
||||
? ThemeManager.CreamAPIBadgeBackgroundHighlightColor
|
||||
: ThemeManager.CreamAPIBadgeBackgroundColor;
|
||||
badgeBorder = ThemeManager.CreamAPIBadgeBorderColor;
|
||||
break;
|
||||
default:
|
||||
badgeBack = highlighted
|
||||
? ThemeManager.DefaultBadgeBackgroundHighlightColor
|
||||
: ThemeManager.DefaultBadgeBackgroundColor;
|
||||
badgeBorder = ThemeManager.DefaultBadgeBorderColor;
|
||||
break;
|
||||
}
|
||||
|
||||
using (SolidBrush badgeBrush = new(badgeBack))
|
||||
graphics.FillRectangle(badgeBrush, badgeBounds);
|
||||
using (Pen badgePen = new(badgeBorder))
|
||||
graphics.DrawRectangle(badgePen, badgeBounds);
|
||||
TextRenderer.DrawText(graphics, badgeText, font,
|
||||
new Point(badgeBounds.X + badgePadding, badgeBounds.Y + 1),
|
||||
Color.White, TextFormatFlags.NoPadding);
|
||||
bounds = bounds with { X = badgeBounds.X, Width = badgeBounds.Width + 2 };
|
||||
}
|
||||
|
||||
// Show Extra Protection checkbox for CreamAPI:
|
||||
// - When CreamAPI is installed, OR
|
||||
// - When no unlocker is installed yet AND user hasn't enabled SmokeAPI mode, OR
|
||||
// - When SmokeAPI is installed BUT user has disabled SmokeAPI mode (about to replace with CreamAPI)
|
||||
bool showExtraProtection = selection.InstalledUnlocker == InstalledUnlocker.CreamAPI ||
|
||||
(selection.InstalledUnlocker == InstalledUnlocker.None && !Program.UseSmokeAPI) ||
|
||||
(selection.InstalledUnlocker == InstalledUnlocker.SmokeAPI && !Program.UseSmokeAPI);
|
||||
|
||||
if (showExtraProtection)
|
||||
{
|
||||
CheckBoxState extraProtState = selection.UseExtraProtection
|
||||
? (Enabled ? CheckBoxState.CheckedNormal : CheckBoxState.CheckedDisabled)
|
||||
: (Enabled ? CheckBoxState.UncheckedNormal : CheckBoxState.UncheckedDisabled);
|
||||
size = CheckBoxRenderer.GetGlyphSize(graphics, extraProtState);
|
||||
bounds = bounds with { X = bounds.X + bounds.Width, Width = size.Width };
|
||||
selectionBounds = new(selectionBounds.Location, selectionBounds.Size + bounds.Size with { Height = 0 });
|
||||
Rectangle extraProtCheckBoxBounds = bounds;
|
||||
graphics.FillRectangle(brush, bounds);
|
||||
point = new(bounds.Left, bounds.Top + bounds.Height / 2 - size.Height / 2 - 1);
|
||||
if (dark)
|
||||
ThemeManager.DrawDarkCheckBox(graphics, point, size, selection.UseExtraProtection, Enabled);
|
||||
else
|
||||
CheckBoxRenderer.DrawCheckBox(graphics, point, extraProtState);
|
||||
|
||||
text = ExtraProtectionToggleString;
|
||||
size = TextRenderer.MeasureText(graphics, text, font);
|
||||
int leftEP = 1;
|
||||
bounds = bounds with { X = bounds.X + bounds.Width, Width = size.Width + leftEP };
|
||||
selectionBounds = new(selectionBounds.Location, selectionBounds.Size + bounds.Size with { Height = 0 });
|
||||
extraProtCheckBoxBounds = new(extraProtCheckBoxBounds.Location, extraProtCheckBoxBounds.Size + bounds.Size with { Height = 0 });
|
||||
graphics.FillRectangle(brush, bounds);
|
||||
point = new(bounds.Location.X - 1 + leftEP, bounds.Location.Y + 1);
|
||||
TextRenderer.DrawText(graphics, text, font, point,
|
||||
Enabled ? ThemeManager.CustomTreeViewProxyColor : ThemeManager.CustomTreeViewDisabledProxyColor,
|
||||
TextFormatFlags.Default);
|
||||
|
||||
extraProtectionCheckBoxBounds[selection] = RectangleToClient(extraProtCheckBoxBounds);
|
||||
|
||||
// Add spacing before proxy checkbox
|
||||
size = new(4, 0);
|
||||
bounds = bounds with { X = bounds.X + bounds.Width, Width = size.Width };
|
||||
graphics.FillRectangle(brush, bounds);
|
||||
}
|
||||
|
||||
CheckBoxState proxyState = selection.UseProxy
|
||||
? (Enabled ? CheckBoxState.CheckedNormal : CheckBoxState.CheckedDisabled)
|
||||
: (Enabled ? CheckBoxState.UncheckedNormal : CheckBoxState.UncheckedDisabled);
|
||||
size = CheckBoxRenderer.GetGlyphSize(graphics, proxyState);
|
||||
bounds = bounds with { X = bounds.X + bounds.Width, Width = size.Width };
|
||||
selectionBounds = new(selectionBounds.Location, selectionBounds.Size + bounds.Size with { Height = 0 });
|
||||
Rectangle checkBoxBounds = bounds;
|
||||
graphics.FillRectangle(backBrush, bounds);
|
||||
graphics.FillRectangle(brush, bounds);
|
||||
point = new(bounds.Left, bounds.Top + bounds.Height / 2 - size.Height / 2 - 1);
|
||||
CheckBoxRenderer.DrawCheckBox(graphics, point, checkBoxState);
|
||||
if (dark)
|
||||
ThemeManager.DrawDarkCheckBox(graphics, point, size, selection.UseProxy, Enabled);
|
||||
else
|
||||
CheckBoxRenderer.DrawCheckBox(graphics, point, proxyState);
|
||||
|
||||
text = ProxyToggleString;
|
||||
size = TextRenderer.MeasureText(graphics, text, font);
|
||||
@@ -187,7 +364,7 @@ internal sealed class CustomTreeView : TreeView
|
||||
bounds = bounds with { X = bounds.X + bounds.Width, Width = size.Width + left };
|
||||
selectionBounds = new(selectionBounds.Location, selectionBounds.Size + bounds.Size with { Height = 0 });
|
||||
checkBoxBounds = new(checkBoxBounds.Location, checkBoxBounds.Size + bounds.Size with { Height = 0 });
|
||||
graphics.FillRectangle(backBrush, bounds);
|
||||
graphics.FillRectangle(brush, bounds);
|
||||
point = new(bounds.Location.X - 1 + left, bounds.Location.Y + 1);
|
||||
TextRenderer.DrawText(graphics, text, font, point,
|
||||
Enabled ? ThemeManager.CustomTreeViewProxyColor : ThemeManager.CustomTreeViewDisabledProxyColor,
|
||||
@@ -312,5 +489,15 @@ internal sealed class CustomTreeView : TreeView
|
||||
selectForm?.OnProxyChanged();
|
||||
break;
|
||||
}
|
||||
|
||||
foreach (KeyValuePair<Selection, Rectangle> pair in extraProtectionCheckBoxBounds)
|
||||
if (!Selection.All.ContainsKey(pair.Key))
|
||||
_ = extraProtectionCheckBoxBounds.Remove(pair.Key);
|
||||
else if (pair.Value.Contains(clickPoint))
|
||||
{
|
||||
pair.Key.UseExtraProtection = !pair.Key.UseExtraProtection;
|
||||
selectForm?.OnExtraProtectionChanged();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@
|
||||
<TargetFramework>net8.0-windows10.0.22621.0</TargetFramework>
|
||||
<UseWindowsForms>True</UseWindowsForms>
|
||||
<ApplicationIcon>Resources\program.ico</ApplicationIcon>
|
||||
<Version>5.0.1.9</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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+15
-2
@@ -33,6 +33,7 @@ namespace CreamInstaller.Forms
|
||||
saveButton = new Button();
|
||||
uninstallAllButton = new Button();
|
||||
selectionTreeView = new CustomTreeView();
|
||||
filterTextBox = new System.Windows.Forms.TextBox();
|
||||
groupBox.SuspendLayout();
|
||||
allCheckBoxFlowPanel.SuspendLayout();
|
||||
SuspendLayout();
|
||||
@@ -51,15 +52,25 @@ namespace CreamInstaller.Forms
|
||||
acceptButton.Text = "OK";
|
||||
acceptButton.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// filterTextBox
|
||||
//
|
||||
filterTextBox.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
|
||||
filterTextBox.Location = new System.Drawing.Point(12, 14);
|
||||
filterTextBox.Name = "filterTextBox";
|
||||
filterTextBox.PlaceholderText = "Enter the name of a game to search";
|
||||
filterTextBox.Size = new System.Drawing.Size(524, 23);
|
||||
filterTextBox.TabIndex = 0;
|
||||
filterTextBox.TextChanged += OnFilterTextChanged;
|
||||
//
|
||||
// groupBox
|
||||
//
|
||||
groupBox.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
|
||||
groupBox.Controls.Add(selectionTreeView);
|
||||
groupBox.Controls.Add(allCheckBoxFlowPanel);
|
||||
groupBox.Location = new System.Drawing.Point(12, 12);
|
||||
groupBox.Location = new System.Drawing.Point(12, 43);
|
||||
groupBox.MinimumSize = new System.Drawing.Size(240, 40);
|
||||
groupBox.Name = "groupBox";
|
||||
groupBox.Size = new System.Drawing.Size(524, 225);
|
||||
groupBox.Size = new System.Drawing.Size(524, 194);
|
||||
groupBox.TabIndex = 3;
|
||||
groupBox.TabStop = false;
|
||||
groupBox.Text = "Choices";
|
||||
@@ -188,6 +199,7 @@ namespace CreamInstaller.Forms
|
||||
Controls.Add(cancelButton);
|
||||
Controls.Add(acceptButton);
|
||||
Controls.Add(groupBox);
|
||||
Controls.Add(filterTextBox);
|
||||
FormBorderStyle = FormBorderStyle.FixedSingle;
|
||||
MaximizeBox = false;
|
||||
MinimizeBox = false;
|
||||
@@ -215,5 +227,6 @@ namespace CreamInstaller.Forms
|
||||
private Button saveButton;
|
||||
private CheckBox sortCheckBox;
|
||||
private Button uninstallAllButton;
|
||||
private System.Windows.Forms.TextBox filterTextBox;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ namespace CreamInstaller.Forms;
|
||||
internal sealed partial class SelectDialogForm : CustomForm
|
||||
{
|
||||
private readonly List<(Platform platform, string id, string name)> selected = new();
|
||||
private readonly List<(Platform platform, string id, string name, bool alreadySelected)> allChoices = new();
|
||||
|
||||
internal SelectDialogForm(IWin32Window owner) : base(owner)
|
||||
{
|
||||
@@ -28,12 +29,12 @@ internal sealed partial class SelectDialogForm : CustomForm
|
||||
allCheckBox.Enabled = false;
|
||||
acceptButton.Enabled = false;
|
||||
selectionTreeView.AfterCheck += OnTreeNodeChecked;
|
||||
foreach ((Platform platform, string id, string name, bool alreadySelected) in potentialChoices)
|
||||
{
|
||||
TreeNode node = new() { Tag = platform, Name = id, Text = name, Checked = alreadySelected };
|
||||
OnTreeNodeChecked(node);
|
||||
_ = selectionTreeView.Nodes.Add(node);
|
||||
}
|
||||
allChoices.Clear();
|
||||
allChoices.AddRange(potentialChoices);
|
||||
foreach ((Platform platform, string id, string name, bool alreadySelected) in allChoices)
|
||||
if (alreadySelected)
|
||||
selected.Add((platform, id, name));
|
||||
ApplyFilter();
|
||||
|
||||
if (selected.Count < 1)
|
||||
OnLoad(null, null);
|
||||
@@ -70,6 +71,32 @@ internal sealed partial class SelectDialogForm : CustomForm
|
||||
allCheckBox.CheckedChanged += OnAllCheckBoxChanged;
|
||||
}
|
||||
|
||||
private void OnFilterTextChanged(object sender, EventArgs e) => ApplyFilter();
|
||||
|
||||
private void ApplyFilter()
|
||||
{
|
||||
string filter = filterTextBox.Text.Trim();
|
||||
selectionTreeView.AfterCheck -= OnTreeNodeChecked;
|
||||
selectionTreeView.BeginUpdate();
|
||||
selectionTreeView.Nodes.Clear();
|
||||
bool hasSelections = selected.Count > 0;
|
||||
foreach ((Platform platform, string id, string name, bool alreadySelected) in allChoices)
|
||||
{
|
||||
if (filter.Length > 0 && name.IndexOf(filter, StringComparison.OrdinalIgnoreCase) < 0)
|
||||
continue;
|
||||
bool checkedState = hasSelections
|
||||
? selected.Any(s => s.platform == platform && s.id == id)
|
||||
: alreadySelected;
|
||||
TreeNode node = new() { Tag = platform, Name = id, Text = name, Checked = checkedState };
|
||||
_ = selectionTreeView.Nodes.Add(node);
|
||||
}
|
||||
selectionTreeView.EndUpdate();
|
||||
selectionTreeView.AfterCheck += OnTreeNodeChecked;
|
||||
allCheckBox.CheckedChanged -= OnAllCheckBoxChanged;
|
||||
allCheckBox.Checked = selectionTreeView.Nodes.Count > 0 && selectionTreeView.Nodes.Cast<TreeNode>().All(n => n.Checked);
|
||||
allCheckBox.CheckedChanged += OnAllCheckBoxChanged;
|
||||
}
|
||||
|
||||
private void OnResize(object s, EventArgs e)
|
||||
=> Text = TextRenderer.MeasureText(Program.ApplicationName, Font).Width > Size.Width - 100
|
||||
? Program.ApplicationNameShort
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -58,13 +58,13 @@ internal static class EpicStore
|
||||
cacheFile.DeleteFile();
|
||||
}
|
||||
|
||||
if (response is null)
|
||||
if (response is null || response.Data?.Catalog is null)
|
||||
return dlcIds;
|
||||
List<Element> searchStore = [..response.Data.Catalog.SearchStore.Elements];
|
||||
List<Element> searchStore = [..response.Data.Catalog.SearchStore?.Elements ?? []];
|
||||
foreach (Element element in searchStore)
|
||||
{
|
||||
string title = element.Title;
|
||||
string product = element.CatalogNs is not null && element.CatalogNs.Mappings.Length > 0
|
||||
string product = element.CatalogNs?.Mappings is { Length: > 0 }
|
||||
? element.CatalogNs.Mappings.First().PageSlug
|
||||
: null;
|
||||
string icon = null;
|
||||
@@ -81,11 +81,11 @@ internal static class EpicStore
|
||||
dlcIds.Populate(item.Id, title, product, icon, null, element.Items.Length == 1);
|
||||
}
|
||||
|
||||
List<Element> catalogOffers = [..response.Data.Catalog.CatalogOffers.Elements];
|
||||
List<Element> catalogOffers = [..response.Data.Catalog.CatalogOffers?.Elements ?? []];
|
||||
foreach (Element element in catalogOffers)
|
||||
{
|
||||
string title = element.Title;
|
||||
string product = element.CatalogNs is not null && element.CatalogNs.Mappings.Length > 0
|
||||
string product = element.CatalogNs?.Mappings is { Length: > 0 }
|
||||
? element.CatalogNs.Mappings.First().PageSlug
|
||||
: null;
|
||||
string icon = null;
|
||||
@@ -118,6 +118,7 @@ internal static class EpicStore
|
||||
(string id, string name, string product, string icon, string developer) app = dlcIds[i];
|
||||
if (app.id != id)
|
||||
continue;
|
||||
|
||||
found = true;
|
||||
dlcIds[i] = canOverwrite
|
||||
? (app.id, title ?? app.name, product ?? app.product, icon ?? app.icon, developer ?? app.developer)
|
||||
@@ -131,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,20 @@ internal static class ThemeManager
|
||||
rtb.ForeColor = DarkFore;
|
||||
break;
|
||||
|
||||
// ListBox follows alternate dark background
|
||||
case ListBox lb:
|
||||
lb.BackColor = DarkBackAlt;
|
||||
lb.ForeColor = DarkFore;
|
||||
break;
|
||||
|
||||
// TextBox follows alternate dark background
|
||||
case TextBox tb:
|
||||
tb.BackColor = DarkBackAlt;
|
||||
tb.ForeColor = DarkFore;
|
||||
tb.BorderStyle = BorderStyle.FixedSingle;
|
||||
NativeMethods.RefreshCueBanner(tb);
|
||||
break;
|
||||
|
||||
// Layout panels set a consistent background
|
||||
case TableLayoutPanel tlp:
|
||||
tlp.BackColor = DarkBack;
|
||||
@@ -241,7 +277,7 @@ internal static class ThemeManager
|
||||
ll.VisitedLinkColor = SystemColors.HotTrack;
|
||||
break;
|
||||
case Label lbl:
|
||||
lbl.BackColor = LightBack;
|
||||
lbl.BackColor = Color.Transparent;
|
||||
lbl.ForeColor = LightFore;
|
||||
break;
|
||||
case ProgressBar pb:
|
||||
@@ -258,6 +294,16 @@ internal static class ThemeManager
|
||||
rtb.BackColor = LightBack;
|
||||
rtb.ForeColor = LightFore;
|
||||
break;
|
||||
case ListBox lb:
|
||||
lb.BackColor = LightBackAlt;
|
||||
lb.ForeColor = LightFore;
|
||||
break;
|
||||
case TextBox tb:
|
||||
tb.BackColor = LightBackAlt;
|
||||
tb.ForeColor = LightFore;
|
||||
tb.BorderStyle = BorderStyle.Fixed3D;
|
||||
NativeMethods.RefreshCueBanner(tb);
|
||||
break;
|
||||
case TableLayoutPanel tlp:
|
||||
tlp.BackColor = LightBack;
|
||||
break;
|
||||
@@ -408,6 +454,96 @@ internal static class ThemeManager
|
||||
// button is centralized here so theming resides in ThemeManager.
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
// Dark checkbox colors – matched to how the system renders the "All" CheckBox control
|
||||
// in dark mode: dark fill, mid-gray border, light foreground tick.
|
||||
private static readonly Color DarkCbBorder = ColorTranslator.FromHtml("#6B6B6B");
|
||||
private static readonly Color DarkCbDisabledBorder = ColorTranslator.FromHtml("#454545");
|
||||
|
||||
/// <summary>
|
||||
/// Draws a checkbox glyph in pure GDI that matches the appearance of a dark-themed
|
||||
/// WinForms CheckBox control (same background, border, tick colors, and rounded corners).
|
||||
/// Use this in owner-draw contexts where CheckBoxRenderer always paints a white background.
|
||||
/// </summary>
|
||||
internal static void DrawDarkCheckBox(Graphics g, Point point, Size glyphSize, bool isChecked, bool enabled = true)
|
||||
{
|
||||
if (g is null) return;
|
||||
int w = glyphSize.Width;
|
||||
int h = glyphSize.Height;
|
||||
Rectangle box = new(point.X, point.Y, w - 1, h - 1);
|
||||
int radius = Math.Max(2, w / 5);
|
||||
|
||||
using System.Drawing.Drawing2D.GraphicsPath path = RoundedRect(box, radius);
|
||||
|
||||
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.AntiAlias;
|
||||
|
||||
if (isChecked && enabled)
|
||||
{
|
||||
// Checked + enabled: accent fill, no border, white tick — matches Windows 11 dark CheckBox
|
||||
using SolidBrush fillBrush = new(Accent);
|
||||
g.FillPath(fillBrush, path);
|
||||
|
||||
using Pen tickPen = new(Color.White, 1.7f)
|
||||
{
|
||||
StartCap = System.Drawing.Drawing2D.LineCap.Round,
|
||||
EndCap = System.Drawing.Drawing2D.LineCap.Round,
|
||||
LineJoin = System.Drawing.Drawing2D.LineJoin.Round,
|
||||
};
|
||||
float scaleX = w / 13f;
|
||||
float scaleY = h / 13f;
|
||||
g.DrawLines(tickPen, new PointF[]
|
||||
{
|
||||
new(point.X + 2 * scaleX, point.Y + 6 * scaleY),
|
||||
new(point.X + 5 * scaleX, point.Y + 9 * scaleY),
|
||||
new(point.X + 10 * scaleX, point.Y + 3 * scaleY),
|
||||
});
|
||||
}
|
||||
else if (isChecked)
|
||||
{
|
||||
// Checked + disabled: dimmed accent fill, dimmed tick
|
||||
Color dimAccent = Color.FromArgb(120, Accent);
|
||||
using SolidBrush fillBrush = new(dimAccent);
|
||||
g.FillPath(fillBrush, path);
|
||||
|
||||
using Pen tickPen = new(DarkForeDim, 1.7f)
|
||||
{
|
||||
StartCap = System.Drawing.Drawing2D.LineCap.Round,
|
||||
EndCap = System.Drawing.Drawing2D.LineCap.Round,
|
||||
LineJoin = System.Drawing.Drawing2D.LineJoin.Round,
|
||||
};
|
||||
float scaleX = w / 13f;
|
||||
float scaleY = h / 13f;
|
||||
g.DrawLines(tickPen, new PointF[]
|
||||
{
|
||||
new(point.X + 2 * scaleX, point.Y + 6 * scaleY),
|
||||
new(point.X + 5 * scaleX, point.Y + 9 * scaleY),
|
||||
new(point.X + 10 * scaleX, point.Y + 3 * scaleY),
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// Unchecked: dark fill, gray border, no tick
|
||||
using SolidBrush fillBrush = new(DarkBackAlt);
|
||||
g.FillPath(fillBrush, path);
|
||||
|
||||
using Pen borderPen = new(enabled ? DarkCbBorder : DarkCbDisabledBorder);
|
||||
g.DrawPath(borderPen, path);
|
||||
}
|
||||
|
||||
g.SmoothingMode = System.Drawing.Drawing2D.SmoothingMode.Default;
|
||||
}
|
||||
|
||||
private static System.Drawing.Drawing2D.GraphicsPath RoundedRect(Rectangle r, int radius)
|
||||
{
|
||||
int d = radius * 2;
|
||||
System.Drawing.Drawing2D.GraphicsPath path = new();
|
||||
path.AddArc(r.Left, r.Top, d, d, 180, 90);
|
||||
path.AddArc(r.Right - d, r.Top, d, d, 270, 90);
|
||||
path.AddArc(r.Right - d, r.Bottom - d, d, d, 0, 90);
|
||||
path.AddArc(r.Left, r.Bottom - d, d, d, 90, 90);
|
||||
path.CloseFigure();
|
||||
return path;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws the themed combobox area (background, border and text) used in CustomTreeView.
|
||||
/// This centralizes colors and rendering for light/dark modes.
|
||||
@@ -450,15 +586,54 @@ internal static class ThemeManager
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps Win32 API calls that have no managed equivalent in WinForms.
|
||||
/// These P/Invoke declarations are required because .NET does not expose
|
||||
/// the underlying Windows messages or DWM attributes through its own APIs.
|
||||
/// </summary>
|
||||
internal static class NativeMethods
|
||||
{
|
||||
// DWM attribute index for enabling/disabling the immersive dark title bar.
|
||||
// Documented in dwmapi.h; value 20 corresponds to DWMWA_USE_IMMERSIVE_DARK_MODE
|
||||
// (Windows 10 build 19041+ / Windows 11).
|
||||
private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20;
|
||||
|
||||
// DwmSetWindowAttribute allows setting per-window Desktop Window Manager attributes.
|
||||
// We use it here to flip the title bar to dark or light depending on the active theme,
|
||||
// since WinForms has no built-in API to control title bar coloring.
|
||||
[System.Runtime.InteropServices.DllImport("dwmapi.dll")]
|
||||
private static extern int DwmSetWindowAttribute(System.IntPtr hwnd, int attr, ref int attrValue, int attrSize);
|
||||
|
||||
/// <summary>
|
||||
/// Toggles the dark/light title bar chrome for the given window handle.
|
||||
/// Pass <c>1</c> for dark mode, <c>0</c> for light mode.
|
||||
/// </summary>
|
||||
internal static void EnableDarkTitleBar(System.IntPtr handle, int useDark)
|
||||
{
|
||||
_ = DwmSetWindowAttribute(handle, DWMWA_USE_IMMERSIVE_DARK_MODE, ref useDark, sizeof(int));
|
||||
}
|
||||
|
||||
// Win32 Edit control message that sets or updates the cue (placeholder) banner text.
|
||||
// WinForms sets PlaceholderText once at creation time via this same message internally,
|
||||
// but does not re-send it when the control's colors change. When we restyle a TextBox
|
||||
// for dark/light mode the cue banner can disappear, so we must re-send the message
|
||||
// manually to make the placeholder visible again.
|
||||
private const int EM_SETCUEBANNER = 0x1501;
|
||||
|
||||
// SendMessage is the standard Win32 mechanism for posting messages directly to a
|
||||
// window/control handle. We use the Unicode variant so the placeholder string is
|
||||
// transmitted without any ANSI conversion.
|
||||
[System.Runtime.InteropServices.DllImport("user32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
|
||||
private static extern System.IntPtr SendMessage(System.IntPtr hWnd, int msg, System.IntPtr wParam, string lParam);
|
||||
|
||||
/// <summary>
|
||||
/// Re-sends <c>EM_SETCUEBANNER</c> to the given TextBox so its placeholder text
|
||||
/// is redrawn after a theme change has altered the control's background or foreground colors.
|
||||
/// Does nothing if the control handle has not yet been created or the placeholder is empty.
|
||||
/// </summary>
|
||||
internal static void RefreshCueBanner(System.Windows.Forms.TextBox textBox)
|
||||
{
|
||||
if (textBox?.IsHandleCreated == true && textBox.PlaceholderText is { Length: > 0 })
|
||||
SendMessage(textBox.Handle, EM_SETCUEBANNER, (System.IntPtr)1, textBox.PlaceholderText);
|
||||
}
|
||||
}
|
||||
@@ -74,14 +74,17 @@ If none of these work, your system may not support .NET 8 or may have underlying
|
||||
|
||||
### DLCs aren't unlocking in my game
|
||||
|
||||
CreamInstaller only installs the unlockers — it does **not** guarantee they will work for every game.
|
||||
CreamInstaller only installs DLC **unlockers** — it does **not** guarantee they will work for every game.
|
||||
|
||||
If the program successfully installed the unlockers but DLCs still aren’t unlocking, this is **not an issue with CreamInstaller itself** and isn’t something I can directly fix.
|
||||
If the program successfully installs the unlockers but DLCs still aren’t unlocking, this is **not an issue with CreamInstaller itself** and isn’t something I can directly fix. DLC Unlocker compatibility and behavior vary from game to game.
|
||||
|
||||
Try the following:
|
||||
**DLC Files:** _This program does **not** automatically download or install actual DLC files for you. As the name implies, it is only a *DLC unlocker installer*. If the game you wish to unlock DLC for does not already include the DLC files (which is the case for many games), you must manually obtain and install those files yourself. This includes manually installing new DLCs and manually updating or reinstalling previously installed DLCs after game updates._
|
||||
|
||||
If you’re having trouble, try the following:
|
||||
|
||||
- Review the [Usage section](https://github.com/FroggMaster/CreamInstaller#usage) for proper setup
|
||||
- Visit the [CS.RIN.RU forum](https://cs.rin.ru/forum/viewforum.php?f=10) for game-specific troubleshooting and compatibility info
|
||||
|
||||
- Review the [Usage section](https://github.com/FroggMaster/CreamInstaller#usage) for proper setup
|
||||
- Visit the [CS.RIN.RU forum](https://cs.rin.ru/forum/viewforum.php?f=10) for game-specific troubleshooting
|
||||
|
||||
---
|
||||
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 39 KiB |
Reference in New Issue
Block a user