mirror of
https://github.com/FroggMaster/CreamInstaller.git
synced 2026-06-12 19:11:25 -07:00
Compare commits
39 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 | |||
| 455a290051 | |||
| 6e8326b84f | |||
| f20ca0d833 | |||
| 788e7f5293 | |||
| ce566cfa47 | |||
| d2a5549878 | |||
| e79aecc023 | |||
| 4b9897bde2 | |||
| 034951e4d2 | |||
| 4075078790 | |||
| 46df791c19 | |||
| b26a5aec48 | |||
| e824ebd713 |
@@ -1,10 +1,40 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Report a program exception or general bug, not including those explained within the FAQ and/or template issues.
|
||||
title: ''
|
||||
about: Report a program exception or general bug (not covered in FAQ or existing issues)
|
||||
title: '[Bug] - '
|
||||
labels: Bug
|
||||
assignees: pointfeev
|
||||
assignees: FroggMaster
|
||||
|
||||
---
|
||||
|
||||
###### Describe the bug and/or provide an image of the exception dialog box:
|
||||
## Bug Description
|
||||
<!-- Provide a clear and concise description of what the bug is -->
|
||||
|
||||
|
||||
## Steps to Reproduce
|
||||
<!-- How can the issue be reproduced? -->
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
|
||||
## Exception Details
|
||||
<!-- If you received an error dialog, provide a screenshot or paste the full error message below -->
|
||||
<details>
|
||||
<summary>Click to expand error message/screenshot</summary>
|
||||
|
||||
```
|
||||
Paste error text here, or drag and drop screenshot below
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
## Generated Config
|
||||
<!-- If a configuration was generated, please provide the configuration file -->
|
||||
|
||||
|
||||
|
||||
## Affected Version
|
||||
<!-- Please specify the version of the application you experience the issue with -->
|
||||
- **CreamInstaller Version:** [e.g. v5.0.1.7, CI build #21]
|
||||
|
||||
@@ -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
|
||||
@@ -44,95 +44,85 @@ internal sealed class ContextMenuItem : ToolStripMenuItem
|
||||
}
|
||||
|
||||
private static async Task TryImageIdentifier(ContextMenuItem item, string imageIdentifier)
|
||||
=> await Task.Run(async () =>
|
||||
{
|
||||
if (Images.TryGetValue(imageIdentifier, out Image image) && image is not null)
|
||||
{
|
||||
if (Images.TryGetValue(imageIdentifier, out Image image) && image is not null)
|
||||
item.Image = image;
|
||||
else
|
||||
item.Image = image;
|
||||
return;
|
||||
}
|
||||
|
||||
image = await Task.Run(async () =>
|
||||
{
|
||||
switch (imageIdentifier)
|
||||
{
|
||||
switch (imageIdentifier)
|
||||
{
|
||||
case "Paradox Launcher":
|
||||
if (ParadoxLauncher.InstallPath.DirectoryExists())
|
||||
foreach (string file in ParadoxLauncher.InstallPath.EnumerateDirectory("*.exe"))
|
||||
{
|
||||
image = file.GetFileIconImage();
|
||||
break;
|
||||
}
|
||||
|
||||
break;
|
||||
case "Notepad":
|
||||
image = IconGrabber.GetNotepadImage();
|
||||
break;
|
||||
case "Command Prompt":
|
||||
image = IconGrabber.GetCommandPromptImage();
|
||||
break;
|
||||
case "File Explorer":
|
||||
image = IconGrabber.GetFileExplorerImage();
|
||||
break;
|
||||
case "SteamDB":
|
||||
image = await HttpClientManager.GetImageFromUrl(
|
||||
IconGrabber.GetDomainFaviconUrl("steamdb.info"));
|
||||
break;
|
||||
case "Steam Store":
|
||||
image = await HttpClientManager.GetImageFromUrl(
|
||||
IconGrabber.GetDomainFaviconUrl("store.steampowered.com"));
|
||||
break;
|
||||
case "Steam Community":
|
||||
image = await HttpClientManager.GetImageFromUrl(
|
||||
IconGrabber.GetDomainFaviconUrl("steamcommunity.com"));
|
||||
break;
|
||||
case "ScreamDB":
|
||||
image = await HttpClientManager.GetImageFromUrl(
|
||||
IconGrabber.GetDomainFaviconUrl("scream-db.web.app"));
|
||||
break;
|
||||
case "Epic Games":
|
||||
image = await HttpClientManager.GetImageFromUrl(
|
||||
IconGrabber.GetDomainFaviconUrl("epicgames.com"));
|
||||
break;
|
||||
case "Ubisoft Store":
|
||||
image = await HttpClientManager.GetImageFromUrl(
|
||||
IconGrabber.GetDomainFaviconUrl("store.ubi.com"));
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
if (image is not null)
|
||||
{
|
||||
Images[imageIdentifier] = image;
|
||||
item.Image = image;
|
||||
}
|
||||
case "Paradox Launcher":
|
||||
if (ParadoxLauncher.InstallPath.DirectoryExists())
|
||||
foreach (string file in ParadoxLauncher.InstallPath.EnumerateDirectory("*.exe"))
|
||||
return file.GetFileIconImage();
|
||||
break;
|
||||
case "Notepad":
|
||||
return IconGrabber.GetNotepadImage();
|
||||
case "Command Prompt":
|
||||
return IconGrabber.GetCommandPromptImage();
|
||||
case "File Explorer":
|
||||
return IconGrabber.GetFileExplorerImage();
|
||||
case "SteamDB":
|
||||
return await HttpClientManager.GetImageFromUrl(
|
||||
IconGrabber.GetDomainFaviconUrl("steamdb.info"));
|
||||
case "Steam Store":
|
||||
return await HttpClientManager.GetImageFromUrl(
|
||||
IconGrabber.GetDomainFaviconUrl("store.steampowered.com"));
|
||||
case "Steam Community":
|
||||
return await HttpClientManager.GetImageFromUrl(
|
||||
IconGrabber.GetDomainFaviconUrl("steamcommunity.com"));
|
||||
case "ScreamDB":
|
||||
return await HttpClientManager.GetImageFromUrl(
|
||||
IconGrabber.GetDomainFaviconUrl("scream-db.web.app"));
|
||||
case "Epic Games":
|
||||
return await HttpClientManager.GetImageFromUrl(
|
||||
IconGrabber.GetDomainFaviconUrl("epicgames.com"));
|
||||
case "Ubisoft Store":
|
||||
return await HttpClientManager.GetImageFromUrl(
|
||||
IconGrabber.GetDomainFaviconUrl("store.ubi.com"));
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
if (image is not null)
|
||||
{
|
||||
Images[imageIdentifier] = image;
|
||||
item.Image = image;
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task TryImageIdentifierInfo(ContextMenuItem item,
|
||||
(string id, string iconUrl) imageIdentifierInfo, Action onFail = null)
|
||||
=> await Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
try
|
||||
(string id, string iconUrl) = imageIdentifierInfo;
|
||||
string imageIdentifier = "Icon_" + id;
|
||||
|
||||
if (Images.TryGetValue(imageIdentifier, out Image image) && image is not null)
|
||||
{
|
||||
(string id, string iconUrl) = imageIdentifierInfo;
|
||||
string imageIdentifier = "Icon_" + id;
|
||||
if (Images.TryGetValue(imageIdentifier, out Image image) && image is not null)
|
||||
item.Image = image;
|
||||
else
|
||||
{
|
||||
image = await HttpClientManager.GetImageFromUrl(iconUrl);
|
||||
if (image is not null)
|
||||
{
|
||||
Images[imageIdentifier] = image;
|
||||
item.Image = image;
|
||||
}
|
||||
else
|
||||
onFail?.Invoke();
|
||||
}
|
||||
item.Image = image;
|
||||
return;
|
||||
}
|
||||
catch
|
||||
|
||||
image = await HttpClientManager.GetImageFromUrl(iconUrl);
|
||||
if (image is not null)
|
||||
{
|
||||
// ignored
|
||||
Images[imageIdentifier] = image;
|
||||
item.Image = image;
|
||||
}
|
||||
});
|
||||
else
|
||||
onFail?.Invoke();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// ignored
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnClick(EventArgs e)
|
||||
{
|
||||
|
||||
@@ -14,21 +14,20 @@ namespace CreamInstaller.Components;
|
||||
internal sealed class CustomTreeView : TreeView
|
||||
{
|
||||
private const string ProxyToggleString = "Proxy";
|
||||
|
||||
private static readonly Color C1 = ColorTranslator.FromHtml("#FFFF99");
|
||||
private static readonly Color C2 = ColorTranslator.FromHtml("#696900");
|
||||
private static readonly Color C3 = ColorTranslator.FromHtml("#AAAA69");
|
||||
private static readonly Color C4 = ColorTranslator.FromHtml("#99FFFF");
|
||||
private static readonly Color C5 = ColorTranslator.FromHtml("#006969");
|
||||
private static readonly Color C6 = ColorTranslator.FromHtml("#69AAAA");
|
||||
private static readonly Color C7 = ColorTranslator.FromHtml("#006900");
|
||||
private static readonly Color C8 = ColorTranslator.FromHtml("#69AA69");
|
||||
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 = [];
|
||||
private SolidBrush backBrush;
|
||||
private Color lastBackColor; // Tracks the last background color
|
||||
|
||||
// Selection background brush (used instead of SystemBrushes.Highlight to support dark mode)
|
||||
private SolidBrush selectionBrush;
|
||||
private Color lastSelectionBackColor;
|
||||
|
||||
private ToolStripDropDown comboBoxDropDown;
|
||||
private Font comboBoxFont;
|
||||
private Form form;
|
||||
@@ -54,6 +53,8 @@ internal sealed class CustomTreeView : TreeView
|
||||
{
|
||||
backBrush?.Dispose();
|
||||
backBrush = null;
|
||||
selectionBrush?.Dispose();
|
||||
selectionBrush = null;
|
||||
comboBoxFont?.Dispose();
|
||||
comboBoxFont = null;
|
||||
comboBoxDropDown?.Dispose();
|
||||
@@ -63,26 +64,140 @@ internal sealed class CustomTreeView : TreeView
|
||||
private void OnInvalidated(object sender, EventArgs e)
|
||||
{
|
||||
checkBoxBounds.Clear();
|
||||
extraProtectionCheckBoxBounds.Clear();
|
||||
comboBoxBounds.Clear();
|
||||
selectionBounds.Clear();
|
||||
backBrush?.Dispose();
|
||||
backBrush = null;
|
||||
lastBackColor = Color.Empty;
|
||||
|
||||
selectionBrush?.Dispose();
|
||||
selectionBrush = null;
|
||||
lastSelectionBackColor = Color.Empty;
|
||||
}
|
||||
|
||||
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;
|
||||
backBrush ??= new(BackColor);
|
||||
|
||||
// Recreate back brush if background color changed
|
||||
if (backBrush == null || lastBackColor != BackColor)
|
||||
{
|
||||
backBrush?.Dispose();
|
||||
backBrush = new(BackColor);
|
||||
lastBackColor = BackColor;
|
||||
}
|
||||
|
||||
// If highlighted, prepare a selection brush that respects the theme
|
||||
if (highlighted)
|
||||
{
|
||||
Color selColor = ThemeManager.CustomTreeViewSelectionBackColor;
|
||||
if (selectionBrush == null || lastSelectionBackColor != selColor)
|
||||
{
|
||||
selectionBrush?.Dispose();
|
||||
selectionBrush = new(selColor);
|
||||
lastSelectionBackColor = selColor;
|
||||
}
|
||||
}
|
||||
|
||||
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 ? SystemBrushes.Highlight : backBrush;
|
||||
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;
|
||||
|
||||
@@ -93,10 +208,10 @@ internal sealed class CustomTreeView : TreeView
|
||||
return;
|
||||
|
||||
Color color = highlighted
|
||||
? C1
|
||||
? ThemeManager.CustomTreeViewHighlightPlatformColor
|
||||
: Enabled
|
||||
? C2
|
||||
: C3;
|
||||
? ThemeManager.CustomTreeViewPlatformColor
|
||||
: ThemeManager.CustomTreeViewDisabledPlatformColor;
|
||||
string text;
|
||||
if (dlcType is not DLCType.None)
|
||||
{
|
||||
@@ -115,10 +230,10 @@ internal sealed class CustomTreeView : TreeView
|
||||
if (platform is not Platform.Paradox)
|
||||
{
|
||||
color = highlighted
|
||||
? C4
|
||||
? ThemeManager.CustomTreeViewHighlightIdColor
|
||||
: Enabled
|
||||
? C5
|
||||
: C6;
|
||||
? ThemeManager.CustomTreeViewIdColor
|
||||
: ThemeManager.CustomTreeViewDisabledIdColor;
|
||||
text = id;
|
||||
size = TextRenderer.MeasureText(graphics, text, font);
|
||||
const int left = -4;
|
||||
@@ -142,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);
|
||||
@@ -161,9 +364,11 @@ 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 ? C7 : C8, TextFormatFlags.Default);
|
||||
TextRenderer.DrawText(graphics, text, font, point,
|
||||
Enabled ? ThemeManager.CustomTreeViewProxyColor : ThemeManager.CustomTreeViewDisabledProxyColor,
|
||||
TextFormatFlags.Default);
|
||||
|
||||
this.checkBoxBounds[selection] = RectangleToClient(checkBoxBounds);
|
||||
|
||||
@@ -171,8 +376,11 @@ internal sealed class CustomTreeView : TreeView
|
||||
{
|
||||
comboBoxFont ??= new(font.FontFamily, 6, font.Style, font.Unit, font.GdiCharSet,
|
||||
font.GdiVerticalFont);
|
||||
ComboBoxState comboBoxState = Enabled ? ComboBoxState.Normal : ComboBoxState.Disabled;
|
||||
ButtonState buttonState = Enabled ? ButtonState.Normal : ButtonState.Inactive;
|
||||
|
||||
bool darkMode = Program.DarkModeEnabled;
|
||||
Color comboBackColor = ThemeManager.CustomTreeViewComboBackColor;
|
||||
Color comboBorderColor = ThemeManager.CustomTreeViewComboBorderColor;
|
||||
Color comboTextColor = ThemeManager.CustomTreeViewComboTextColor;
|
||||
|
||||
text = (selection.Proxy ?? Selection.DefaultProxy) + ".dll";
|
||||
size = TextRenderer.MeasureText(graphics, text, comboBoxFont) + new Size(6, 0);
|
||||
@@ -181,18 +389,9 @@ internal sealed class CustomTreeView : TreeView
|
||||
selectionBounds = new(selectionBounds.Location,
|
||||
selectionBounds.Size + bounds.Size with { Height = 0 });
|
||||
Rectangle comboBoxBounds = bounds;
|
||||
graphics.FillRectangle(backBrush, bounds);
|
||||
if (ComboBoxRenderer.IsSupported)
|
||||
ComboBoxRenderer.DrawTextBox(graphics, bounds, text, comboBoxFont, comboBoxState);
|
||||
else
|
||||
{
|
||||
graphics.FillRectangle(SystemBrushes.ControlText, bounds);
|
||||
ControlPaint.DrawButton(graphics, bounds, buttonState);
|
||||
point = new(bounds.Location.X + 3 + bounds.Width / 2 - size.Width / 2,
|
||||
bounds.Location.Y + bounds.Height / 2 - size.Height / 2);
|
||||
TextRenderer.DrawText(graphics, text, comboBoxFont, point,
|
||||
Enabled ? SystemColors.ControlText : SystemColors.GrayText, TextFormatFlags.Default);
|
||||
}
|
||||
|
||||
// Themed combobox background + text (centralized in ThemeManager)
|
||||
ThemeManager.DrawCustomComboBox(graphics, bounds, comboBoxFont, text);
|
||||
|
||||
size = new(14, 0);
|
||||
left = -1;
|
||||
@@ -201,10 +400,9 @@ internal sealed class CustomTreeView : TreeView
|
||||
selectionBounds.Size + new Size(bounds.Size.Width + left, 0));
|
||||
comboBoxBounds = new(comboBoxBounds.Location,
|
||||
comboBoxBounds.Size + new Size(bounds.Size.Width + left, 0));
|
||||
if (ComboBoxRenderer.IsSupported)
|
||||
ComboBoxRenderer.DrawDropDownButton(graphics, bounds, comboBoxState);
|
||||
else
|
||||
ControlPaint.DrawComboButton(graphics, bounds, buttonState);
|
||||
|
||||
// Themed combobox dropdown button (centralized in ThemeManager)
|
||||
ThemeManager.DrawCustomComboBoxButton(graphics, bounds);
|
||||
|
||||
this.comboBoxBounds[selection] = RectangleToClient(comboBoxBounds);
|
||||
}
|
||||
@@ -246,6 +444,7 @@ internal sealed class CustomTreeView : TreeView
|
||||
comboBoxDropDown ??= new();
|
||||
comboBoxDropDown.ShowItemToolTips = false;
|
||||
comboBoxDropDown.Items.Clear();
|
||||
|
||||
foreach (string proxy in proxies)
|
||||
{
|
||||
bool canUse = true;
|
||||
@@ -261,13 +460,22 @@ internal sealed class CustomTreeView : TreeView
|
||||
}
|
||||
|
||||
if (canUse)
|
||||
_ = comboBoxDropDown.Items.Add(new ToolStripButton(proxy + ".dll", null, (_, _) =>
|
||||
{
|
||||
ToolStripMenuItem menuItem = new(proxy + ".dll", null, (_, _) =>
|
||||
{
|
||||
pair.Key.Proxy = proxy == Selection.DefaultProxy ? null : proxy;
|
||||
selectForm.OnProxyChanged();
|
||||
}) { Font = comboBoxFont });
|
||||
})
|
||||
{
|
||||
Font = comboBoxFont
|
||||
};
|
||||
_ = comboBoxDropDown.Items.Add(menuItem);
|
||||
}
|
||||
}
|
||||
|
||||
// Apply theme using ThemeManager
|
||||
ThemeManager.ApplyToolStripDropDown(comboBoxDropDown);
|
||||
|
||||
comboBoxDropDown.Show(this, PointToScreen(new(pair.Value.Left, pair.Value.Bottom - 1)));
|
||||
break;
|
||||
}
|
||||
@@ -281,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.8</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)
|
||||
@@ -791,6 +819,7 @@ internal sealed partial class SelectForm : CustomForm
|
||||
=> Invoke(() =>
|
||||
{
|
||||
ContextMenuStrip contextMenuStrip = new();
|
||||
ThemeManager.ApplyContextMenu(contextMenuStrip);
|
||||
ToolStripItemCollection items = contextMenuStrip.Items;
|
||||
string id = node.Name;
|
||||
Platform platform = (Platform)node.Tag;
|
||||
@@ -971,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1062,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)
|
||||
{
|
||||
@@ -1096,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();
|
||||
}
|
||||
@@ -1104,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)
|
||||
{
|
||||
@@ -1146,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();
|
||||
}
|
||||
|
||||
@@ -1155,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)
|
||||
{
|
||||
@@ -1169,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();
|
||||
@@ -1184,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;
|
||||
@@ -1218,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)
|
||||
@@ -1242,14 +1403,8 @@ internal sealed partial class SelectForm : CustomForm
|
||||
|
||||
private void OnDarkModeCheckBoxChanged(object sender, EventArgs e)
|
||||
{
|
||||
bool requestedDark = darkModeCheckBox.Checked;
|
||||
if (Program.DarkModeEnabled != requestedDark)
|
||||
{
|
||||
Program.DarkModeEnabled = requestedDark;
|
||||
ThemeManager.Apply(this);
|
||||
}
|
||||
else
|
||||
ThemeManager.Apply(this);
|
||||
Program.DarkModeEnabled = darkModeCheckBox.Checked;
|
||||
ThemeManager.ApplyToAllOpenForms();
|
||||
}
|
||||
|
||||
protected override void OnShown(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);
|
||||
}
|
||||
}
|
||||
@@ -7,200 +7,633 @@ namespace CreamInstaller.Utility;
|
||||
|
||||
internal static class ThemeManager
|
||||
{
|
||||
// VS-like dark colors
|
||||
private static readonly Color DarkBack = ColorTranslator.FromHtml("#1E1E1E");
|
||||
private static readonly Color DarkBackAlt = ColorTranslator.FromHtml("#252525");
|
||||
private static readonly Color DarkBorder = ColorTranslator.FromHtml("#3F3F46");
|
||||
private static readonly Color DarkFore = ColorTranslator.FromHtml("#D4D4D4");
|
||||
private static readonly Color DarkForeDim = ColorTranslator.FromHtml("#9CA3AF");
|
||||
private static readonly Color Accent = ColorTranslator.FromHtml("#0E639C");
|
||||
private static readonly Color DarkLink = ColorTranslator.FromHtml("#64B5F6");
|
||||
private static readonly Color LightBack = SystemColors.Control;
|
||||
private static readonly Color LightBackAlt = SystemColors.ControlLightLight;
|
||||
private static readonly Color LightFore = SystemColors.ControlText;
|
||||
private static readonly Color LightBorder = SystemColors.ControlDark;
|
||||
// -----------------------------------------------------------------
|
||||
// Color definitions (do not change values)
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
internal static void ToggleDarkMode(Form anyForm)
|
||||
{
|
||||
Program.DarkModeEnabled = !Program.DarkModeEnabled;
|
||||
ApplyToAllOpenForms();
|
||||
}
|
||||
// ----------------------------
|
||||
// Dark mode colors
|
||||
// ----------------------------
|
||||
private static readonly Color DarkBack = ColorTranslator.FromHtml("#1E1E1E");
|
||||
private static readonly Color DarkBackAlt = ColorTranslator.FromHtml("#252525");
|
||||
private static readonly Color DarkBorder = ColorTranslator.FromHtml("#3F3F46");
|
||||
private static readonly Color DarkFore = ColorTranslator.FromHtml("#D4D4D4");
|
||||
private static readonly Color DarkForeDim = ColorTranslator.FromHtml("#9CA3AF");
|
||||
private static readonly Color Accent = ColorTranslator.FromHtml("#0E639C");
|
||||
private static readonly Color DarkLink = ColorTranslator.FromHtml("#64B5F6");
|
||||
|
||||
internal static void Apply(Form form)
|
||||
{
|
||||
if (form is null) return;
|
||||
if (!Program.DarkModeEnabled)
|
||||
{
|
||||
Reset(form);
|
||||
return;
|
||||
}
|
||||
form.SuspendLayout();
|
||||
form.BackColor = DarkBack;
|
||||
form.ForeColor = DarkFore;
|
||||
ApplyTitleBar(form);
|
||||
foreach (Control c in form.Controls)
|
||||
ApplyControlTheme(c, true);
|
||||
form.ResumeLayout(true);
|
||||
}
|
||||
// CustomTreeView dark-mode specific colors
|
||||
private static readonly Color DarkPlatform = ColorTranslator.FromHtml("#FFFF99");
|
||||
private static readonly Color DarkId = ColorTranslator.FromHtml("#99FFFF");
|
||||
private static readonly Color DarkProxy = ColorTranslator.FromHtml("#99FF99");
|
||||
private static readonly Color DarkSelectionBack = ColorTranslator.FromHtml("#2A2D2E");
|
||||
private static readonly Color DarkComboBack = DarkBackAlt; // #252525
|
||||
private static readonly Color DarkComboBorder = DarkBorder; // #3F3F46
|
||||
private static readonly Color DarkComboText = DarkFore; // #D4D4D4
|
||||
|
||||
private static void ApplyControlTheme(Control control, bool dark)
|
||||
{
|
||||
if (control is null) return;
|
||||
foreach (Control child in control.Controls)
|
||||
ApplyControlTheme(child, dark);
|
||||
if (dark)
|
||||
{
|
||||
switch (control)
|
||||
{
|
||||
case GroupBox gb:
|
||||
gb.ForeColor = DarkFore;
|
||||
gb.BackColor = DarkBackAlt;
|
||||
break;
|
||||
case Button b:
|
||||
b.FlatStyle = FlatStyle.Flat;
|
||||
b.FlatAppearance.BorderColor = DarkBorder;
|
||||
b.BackColor = DarkBackAlt;
|
||||
b.ForeColor = DarkFore;
|
||||
break;
|
||||
case CheckBox cb:
|
||||
cb.BackColor = DarkBack;
|
||||
cb.ForeColor = DarkFore;
|
||||
break;
|
||||
case LinkLabel ll:
|
||||
ll.BackColor = DarkBack;
|
||||
ll.ForeColor = DarkFore; // normal text
|
||||
ll.LinkColor = DarkLink;
|
||||
ll.ActiveLinkColor = Color.White; // high contrast when pressed
|
||||
ll.VisitedLinkColor = DarkLink; // keep consistent
|
||||
break;
|
||||
case Label lbl:
|
||||
lbl.BackColor = DarkBack;
|
||||
lbl.ForeColor = DarkFore;
|
||||
break;
|
||||
case ProgressBar pb:
|
||||
pb.ForeColor = Accent;
|
||||
pb.BackColor = DarkBackAlt;
|
||||
break;
|
||||
case TreeView tv:
|
||||
tv.BackColor = DarkBackAlt;
|
||||
tv.ForeColor = DarkFore;
|
||||
tv.LineColor = DarkBorder;
|
||||
break;
|
||||
case RichTextBox rtb:
|
||||
rtb.BackColor = DarkBackAlt;
|
||||
rtb.ForeColor = DarkFore;
|
||||
break;
|
||||
case TableLayoutPanel tlp:
|
||||
tlp.BackColor = DarkBack;
|
||||
break;
|
||||
case FlowLayoutPanel flp:
|
||||
flp.BackColor = DarkBack;
|
||||
break;
|
||||
}
|
||||
TryApplyScrollbarTheme(control, true);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Light reset per control type
|
||||
switch (control)
|
||||
{
|
||||
case GroupBox gb:
|
||||
gb.BackColor = LightBack;
|
||||
gb.ForeColor = LightFore;
|
||||
break;
|
||||
case Button b:
|
||||
b.FlatStyle = FlatStyle.Standard;
|
||||
b.BackColor = LightBack;
|
||||
b.ForeColor = LightFore;
|
||||
break;
|
||||
case CheckBox cb:
|
||||
cb.BackColor = LightBack;
|
||||
cb.ForeColor = LightFore;
|
||||
break;
|
||||
case LinkLabel ll:
|
||||
ll.BackColor = LightBack;
|
||||
ll.ForeColor = LightFore;
|
||||
// allow system defaults for link colors
|
||||
ll.LinkColor = SystemColors.HotTrack;
|
||||
ll.ActiveLinkColor = SystemColors.Highlight;
|
||||
ll.VisitedLinkColor = SystemColors.HotTrack;
|
||||
break;
|
||||
case Label lbl:
|
||||
lbl.BackColor = LightBack;
|
||||
lbl.ForeColor = LightFore;
|
||||
break;
|
||||
case ProgressBar pb:
|
||||
pb.BackColor = LightBack;
|
||||
pb.ForeColor = LightFore;
|
||||
break;
|
||||
case TreeView tv:
|
||||
tv.BackColor = LightBack;
|
||||
tv.ForeColor = LightFore;
|
||||
tv.LineColor = LightBorder;
|
||||
break;
|
||||
case RichTextBox rtb:
|
||||
rtb.BackColor = LightBack;
|
||||
rtb.ForeColor = LightFore;
|
||||
break;
|
||||
case TableLayoutPanel tlp:
|
||||
tlp.BackColor = LightBack;
|
||||
break;
|
||||
case FlowLayoutPanel flp:
|
||||
flp.BackColor = LightBack;
|
||||
break;
|
||||
}
|
||||
TryApplyScrollbarTheme(control, false);
|
||||
}
|
||||
}
|
||||
// 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");
|
||||
|
||||
private static void Reset(Form form)
|
||||
{
|
||||
form.SuspendLayout();
|
||||
form.BackColor = LightBack;
|
||||
form.ForeColor = LightFore;
|
||||
ApplyTitleBar(form);
|
||||
foreach (Control c in form.Controls)
|
||||
ApplyControlTheme(c, false);
|
||||
form.ResumeLayout(true);
|
||||
}
|
||||
// ----------------------------
|
||||
// Light mode colors (system defaults)
|
||||
// ----------------------------
|
||||
private static readonly Color LightBack = SystemColors.Control;
|
||||
private static readonly Color LightBackAlt = SystemColors.ControlLightLight;
|
||||
private static readonly Color LightFore = SystemColors.ControlText;
|
||||
private static readonly Color LightBorder = SystemColors.ControlDark;
|
||||
|
||||
private static void ApplyToAllOpenForms()
|
||||
{
|
||||
foreach (Form openForm in Application.OpenForms.Cast<Form>())
|
||||
Apply(openForm);
|
||||
}
|
||||
// CustomTreeView light-mode specific colors
|
||||
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 = ColorTranslator.FromHtml("#ADD6FF");
|
||||
private static readonly Color LightComboBack = SystemColors.Control;
|
||||
private static readonly Color LightComboBorder = SystemColors.ControlDark;
|
||||
private static readonly Color LightComboText = SystemColors.ControlText;
|
||||
|
||||
private static void ApplyTitleBar(Form form)
|
||||
{
|
||||
try
|
||||
{
|
||||
int useDark = Program.DarkModeEnabled ?1 :0;
|
||||
NativeMethods.EnableDarkTitleBar(form.Handle, useDark);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
// -----------------------------------------------------------------
|
||||
// Theme-aware properties used by other components (CustomTreeView etc.)
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
private static void TryApplyScrollbarTheme(Control control, bool dark)
|
||||
{
|
||||
// RichTextBox & TreeView host scrollbars internally; use window theme API
|
||||
try
|
||||
{
|
||||
string theme = dark ? "DarkMode_Explorer" : null; // reset with null
|
||||
NativeImports.SetWindowTheme(control.Handle, theme, null);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
internal static bool IsDark => Program.DarkModeEnabled;
|
||||
|
||||
internal static Color CustomTreeViewPlatformColor => IsDark ? DarkPlatform : LightPlatform;
|
||||
|
||||
internal static Color CustomTreeViewIdColor => IsDark ? DarkId : LightId;
|
||||
|
||||
internal static Color CustomTreeViewProxyColor => IsDark ? DarkProxy : LightProxy;
|
||||
|
||||
internal static Color CustomTreeViewHighlightPlatformColor => DarkPlatform; // C1 (uses same color for highlight)
|
||||
internal static Color CustomTreeViewDisabledPlatformColor => ColorTranslator.FromHtml("#AAAA69"); // C3
|
||||
internal static Color CustomTreeViewHighlightIdColor => DarkId; // C4
|
||||
internal static Color CustomTreeViewDisabledIdColor => ColorTranslator.FromHtml("#69AAAA"); // C6
|
||||
internal static Color CustomTreeViewDisabledProxyColor => ColorTranslator.FromHtml("#69AA69"); // C8
|
||||
|
||||
// Background color used when a tree node is selected.
|
||||
// Keeps light-mode behavior using the system highlight, but supplies a custom dark color for dark mode
|
||||
internal static Color CustomTreeViewSelectionBackColor => IsDark ? DarkSelectionBack : LightSelectionBack;
|
||||
|
||||
internal static Color CustomTreeViewComboBackColor => IsDark ? DarkComboBack : LightComboBack;
|
||||
|
||||
internal static Color CustomTreeViewComboBorderColor => IsDark ? DarkComboBorder : LightComboBorder;
|
||||
|
||||
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
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Toggle dark mode and re-apply theming to all open forms.
|
||||
/// </summary>
|
||||
internal static void ToggleDarkMode(Form anyForm)
|
||||
{
|
||||
Program.DarkModeEnabled = !Program.DarkModeEnabled;
|
||||
ApplyToAllOpenForms();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply current theme to a single form and its child controls.
|
||||
/// </summary>
|
||||
internal static void Apply(Form form)
|
||||
{
|
||||
if (form is null) return;
|
||||
if (!IsDark)
|
||||
{
|
||||
Reset(form);
|
||||
return;
|
||||
}
|
||||
|
||||
form.SuspendLayout();
|
||||
form.BackColor = DarkBack;
|
||||
form.ForeColor = DarkFore;
|
||||
ApplyTitleBar(form);
|
||||
|
||||
foreach (Control c in form.Controls)
|
||||
ApplyControlTheme(c, true);
|
||||
|
||||
form.ResumeLayout(true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Apply the theme to all currently open forms.
|
||||
/// </summary>
|
||||
internal static void ApplyToAllOpenForms()
|
||||
{
|
||||
foreach (Form openForm in Application.OpenForms.Cast<Form>())
|
||||
Apply(openForm);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Control theming helpers
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Apply theming to a control tree. Entry point which recurses children
|
||||
/// then applies either the dark or light styling logic.
|
||||
/// </summary>
|
||||
private static void ApplyControlTheme(Control control, bool dark)
|
||||
{
|
||||
if (control is null) return;
|
||||
|
||||
// Recurse first so parent layering still works correctly
|
||||
foreach (Control child in control.Controls)
|
||||
ApplyControlTheme(child, dark);
|
||||
|
||||
if (dark)
|
||||
ApplyDarkControl(control);
|
||||
else
|
||||
ApplyLightControl(control);
|
||||
|
||||
// Try to apply themed scrollbars where applicable
|
||||
TryApplyScrollbarTheme(control, dark);
|
||||
}
|
||||
|
||||
// Separated dark/light cases to make the intent clearer and reduce duplication
|
||||
private static void ApplyDarkControl(Control control)
|
||||
{
|
||||
switch (control)
|
||||
{
|
||||
// Group box background/foreground
|
||||
case GroupBox gb:
|
||||
gb.ForeColor = DarkFore;
|
||||
gb.BackColor = DarkBackAlt;
|
||||
break;
|
||||
|
||||
// Buttons: flat appearance, border and foreground
|
||||
case Button b:
|
||||
b.FlatStyle = FlatStyle.Flat;
|
||||
b.FlatAppearance.BorderColor = DarkBorder;
|
||||
b.BackColor = DarkBackAlt;
|
||||
b.ForeColor = DarkFore;
|
||||
break;
|
||||
|
||||
// Checkboxes: match form background and foreground
|
||||
case CheckBox cb:
|
||||
cb.BackColor = DarkBack;
|
||||
cb.ForeColor = DarkFore;
|
||||
break;
|
||||
|
||||
// LinkLabel: color and active/visited styling
|
||||
case LinkLabel ll:
|
||||
ll.BackColor = DarkBack;
|
||||
ll.ForeColor = DarkFore;
|
||||
ll.LinkColor = DarkLink;
|
||||
ll.ActiveLinkColor = Color.White;
|
||||
ll.VisitedLinkColor = DarkLink;
|
||||
break;
|
||||
|
||||
// Labels: transparent so they blend with whatever container they sit in
|
||||
case Label lbl:
|
||||
lbl.BackColor = Color.Transparent;
|
||||
lbl.ForeColor = DarkFore;
|
||||
break;
|
||||
|
||||
// ProgressBar uses accent color for foreground
|
||||
case ProgressBar pb:
|
||||
pb.ForeColor = Accent;
|
||||
pb.BackColor = DarkBackAlt;
|
||||
break;
|
||||
|
||||
// TreeView: darker alternate background, light text, darker lines
|
||||
case TreeView tv:
|
||||
tv.BackColor = DarkBackAlt;
|
||||
tv.ForeColor = DarkFore;
|
||||
tv.LineColor = DarkBorder;
|
||||
tv.Invalidate(); // Forces a redraw
|
||||
break;
|
||||
|
||||
// RichTextBox follows alternate dark background
|
||||
case RichTextBox rtb:
|
||||
rtb.BackColor = DarkBackAlt;
|
||||
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;
|
||||
break;
|
||||
case FlowLayoutPanel flp:
|
||||
flp.BackColor = DarkBack;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyLightControl(Control control)
|
||||
{
|
||||
switch (control)
|
||||
{
|
||||
case GroupBox gb:
|
||||
gb.BackColor = LightBack;
|
||||
gb.ForeColor = LightFore;
|
||||
break;
|
||||
case Button b:
|
||||
b.FlatStyle = FlatStyle.Standard;
|
||||
b.BackColor = LightBack;
|
||||
b.ForeColor = LightFore;
|
||||
break;
|
||||
case CheckBox cb:
|
||||
cb.BackColor = LightBack;
|
||||
cb.ForeColor = LightFore;
|
||||
break;
|
||||
case LinkLabel ll:
|
||||
ll.BackColor = LightBack;
|
||||
ll.ForeColor = LightFore;
|
||||
ll.LinkColor = SystemColors.HotTrack;
|
||||
ll.ActiveLinkColor = SystemColors.Highlight;
|
||||
ll.VisitedLinkColor = SystemColors.HotTrack;
|
||||
break;
|
||||
case Label lbl:
|
||||
lbl.BackColor = Color.Transparent;
|
||||
lbl.ForeColor = LightFore;
|
||||
break;
|
||||
case ProgressBar pb:
|
||||
pb.BackColor = LightBack;
|
||||
pb.ForeColor = LightFore;
|
||||
break;
|
||||
case TreeView tv:
|
||||
tv.BackColor = LightBack;
|
||||
tv.ForeColor = LightFore;
|
||||
tv.LineColor = LightBorder;
|
||||
tv.Invalidate(); // Forces a redraw
|
||||
break;
|
||||
case RichTextBox rtb:
|
||||
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;
|
||||
case FlowLayoutPanel flp:
|
||||
flp.BackColor = LightBack;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void Reset(Form form)
|
||||
{
|
||||
form.SuspendLayout();
|
||||
form.BackColor = LightBack;
|
||||
form.ForeColor = LightFore;
|
||||
ApplyTitleBar(form);
|
||||
foreach (Control c in form.Controls)
|
||||
ApplyControlTheme(c, false);
|
||||
form.ResumeLayout(true);
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Titlebar / platform-specific helpers
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
private static void ApplyTitleBar(Form form)
|
||||
{
|
||||
try
|
||||
{
|
||||
int useDark = IsDark ? 1 : 0;
|
||||
NativeMethods.EnableDarkTitleBar(form.Handle, useDark);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static void TryApplyScrollbarTheme(Control control, bool dark)
|
||||
{
|
||||
try
|
||||
{
|
||||
string theme = dark ? "DarkMode_Explorer" : null;
|
||||
NativeImports.SetWindowTheme(control.Handle, theme, null);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Context menu / ToolStrip theming
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Apply theme to a context menu (ContextMenuStrip).
|
||||
/// </summary>
|
||||
internal static void ApplyContextMenu(ContextMenuStrip contextMenu)
|
||||
{
|
||||
if (contextMenu is null) return;
|
||||
|
||||
bool dark = IsDark;
|
||||
|
||||
contextMenu.BackColor = dark ? DarkBackAlt : SystemColors.Menu;
|
||||
contextMenu.ForeColor = dark ? DarkFore : SystemColors.MenuText;
|
||||
contextMenu.Renderer = dark ? new DarkContextMenuRenderer() : new ToolStripProfessionalRenderer();
|
||||
|
||||
foreach (ToolStripItem item in contextMenu.Items)
|
||||
ApplyContextMenuItem(item, dark);
|
||||
}
|
||||
|
||||
private static void ApplyContextMenuItem(ToolStripItem item, bool dark)
|
||||
{
|
||||
if (item is null) return;
|
||||
|
||||
item.BackColor = dark ? DarkBackAlt : SystemColors.Menu;
|
||||
item.ForeColor = dark ? DarkFore : SystemColors.MenuText;
|
||||
|
||||
if (item is ToolStripMenuItem menuItem)
|
||||
foreach (ToolStripItem subItem in menuItem.DropDownItems)
|
||||
ApplyContextMenuItem(subItem, dark);
|
||||
}
|
||||
|
||||
internal static void ApplyToolStripDropDown(ToolStripDropDown dropDown)
|
||||
{
|
||||
if (dropDown is null) return;
|
||||
|
||||
bool dark = IsDark;
|
||||
|
||||
dropDown.BackColor = dark ? DarkBackAlt : SystemColors.Menu;
|
||||
dropDown.ForeColor = dark ? DarkFore : SystemColors.MenuText;
|
||||
dropDown.Renderer = dark ? new DarkDropDownRenderer() : new ToolStripProfessionalRenderer();
|
||||
|
||||
foreach (ToolStripItem item in dropDown.Items)
|
||||
ApplyToolStripItem(item, dark);
|
||||
}
|
||||
|
||||
private static void ApplyToolStripItem(ToolStripItem item, bool dark)
|
||||
{
|
||||
if (item is null) return;
|
||||
|
||||
item.BackColor = dark ? DarkBackAlt : SystemColors.Menu;
|
||||
item.ForeColor = dark ? DarkFore : SystemColors.MenuText;
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Themed renderers for menus
|
||||
// -----------------------------------------------------------------
|
||||
|
||||
private class DarkContextMenuRenderer : ToolStripProfessionalRenderer
|
||||
{
|
||||
public DarkContextMenuRenderer() : base(new DarkMenuColorTable()) { }
|
||||
|
||||
protected override void OnRenderItemText(ToolStripItemTextRenderEventArgs e)
|
||||
{
|
||||
if (e.Item.Selected)
|
||||
e.TextColor = DarkFore;
|
||||
base.OnRenderItemText(e);
|
||||
}
|
||||
}
|
||||
|
||||
private class DarkDropDownRenderer : ToolStripProfessionalRenderer
|
||||
{
|
||||
public DarkDropDownRenderer() : base(new DarkMenuColorTable()) { }
|
||||
|
||||
protected override void OnRenderItemText(ToolStripItemTextRenderEventArgs e)
|
||||
{
|
||||
// Force text color to stay light even when selected
|
||||
e.TextColor = DarkFore;
|
||||
base.OnRenderItemText(e);
|
||||
}
|
||||
}
|
||||
|
||||
private class DarkMenuColorTable : ProfessionalColorTable
|
||||
{
|
||||
public override Color MenuItemSelected => ColorTranslator.FromHtml("#2A2D2E");
|
||||
public override Color MenuItemSelectedGradientBegin => ColorTranslator.FromHtml("#2A2D2E");
|
||||
public override Color MenuItemSelectedGradientEnd => ColorTranslator.FromHtml("#2A2D2E");
|
||||
public override Color MenuItemBorder => ColorTranslator.FromHtml("#3F3F46");
|
||||
public override Color MenuBorder => ColorTranslator.FromHtml("#3F3F46");
|
||||
public override Color MenuItemPressedGradientBegin => ColorTranslator.FromHtml("#252525");
|
||||
public override Color MenuItemPressedGradientEnd => ColorTranslator.FromHtml("#252525");
|
||||
public override Color ImageMarginGradientBegin => ColorTranslator.FromHtml("#1E1E1E");
|
||||
public override Color ImageMarginGradientMiddle => ColorTranslator.FromHtml("#1E1E1E");
|
||||
public override Color ImageMarginGradientEnd => ColorTranslator.FromHtml("#1E1E1E");
|
||||
public override Color ToolStripDropDownBackground => ColorTranslator.FromHtml("#252525");
|
||||
public override Color SeparatorDark => ColorTranslator.FromHtml("#3F3F46");
|
||||
public override Color SeparatorLight => ColorTranslator.FromHtml("#3F3F46");
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Theming helpers for CustomTreeView
|
||||
// All rendering logic for the CustomTreeView's proxy combo box and dropdown
|
||||
// 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.
|
||||
/// </summary>
|
||||
internal static void DrawCustomComboBox(Graphics graphics, Rectangle rect, Font font, string text)
|
||||
{
|
||||
if (graphics is null) return;
|
||||
using SolidBrush comboBrush = new(CustomTreeViewComboBackColor);
|
||||
using Pen borderPen = new(CustomTreeViewComboBorderColor);
|
||||
graphics.FillRectangle(comboBrush, rect);
|
||||
graphics.DrawRectangle(borderPen, rect);
|
||||
// Draw text inside the combobox
|
||||
Size textSize = TextRenderer.MeasureText(graphics, text, font);
|
||||
Point textPoint = new(rect.Left +3, rect.Top + rect.Height /2 - textSize.Height /2);
|
||||
TextRenderer.DrawText(graphics, text, font, textPoint, CustomTreeViewComboTextColor, TextFormatFlags.Default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Draws the themed dropdown button (right-side arrow) used in CustomTreeView comboboxes.
|
||||
/// </summary>
|
||||
internal static void DrawCustomComboBoxButton(Graphics graphics, Rectangle rect)
|
||||
{
|
||||
if (graphics is null) return;
|
||||
using SolidBrush comboBrush = new(CustomTreeViewComboBackColor);
|
||||
using Pen borderPen = new(CustomTreeViewComboBorderColor);
|
||||
graphics.FillRectangle(comboBrush, rect);
|
||||
graphics.DrawRectangle(borderPen, rect);
|
||||
|
||||
// Draw the arrow glyph centered in the rect
|
||||
int arrowSize =3;
|
||||
Point arrowTop = new(rect.X + rect.Width /2, rect.Y + rect.Height /2 -1);
|
||||
Point[] arrowPoints = new[]
|
||||
{
|
||||
arrowTop,
|
||||
new Point(arrowTop.X - arrowSize, arrowTop.Y - arrowSize),
|
||||
new Point(arrowTop.X + arrowSize, arrowTop.Y - arrowSize)
|
||||
};
|
||||
using SolidBrush arrowBrush = new(CustomTreeViewComboTextColor);
|
||||
graphics.FillPolygon(arrowBrush, arrowPoints);
|
||||
}
|
||||
}
|
||||
|
||||
/// <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
|
||||
{
|
||||
private const int DWMWA_USE_IMMERSIVE_DARK_MODE =20;
|
||||
// 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;
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("dwmapi.dll")]
|
||||
private static extern int DwmSetWindowAttribute(System.IntPtr hwnd, int attr, ref int attrValue, int attrSize);
|
||||
// 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);
|
||||
|
||||
internal static void EnableDarkTitleBar(System.IntPtr handle, int useDark)
|
||||
{
|
||||
_ = DwmSetWindowAttribute(handle, DWMWA_USE_IMMERSIVE_DARK_MODE, ref useDark, sizeof(int));
|
||||
}
|
||||
}
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
@@ -57,16 +57,80 @@ If the program doesn't seem to launch, try downloading and installing [.NET Desk
|
||||
##### **NOTE:** This program does not automatically download nor install actual DLC files for you; as the title of the program states, this program is only a *DLC Unlocker* installer. Should the game you wish to unlock DLC for not already come with the DLCs installed, as is the case with a good majority of games, you must find, download and install those to the game yourself. This process includes manually installing new DLCs and manually updating the previously manually installed DLCs after game updates.
|
||||
|
||||
---
|
||||
#### FAQ / Common Issues:
|
||||
# FAQ / Common Issues
|
||||
|
||||
**Q:** The program is not launching.
|
||||
**A:** First and foremost, note that the program currently only supports Windows 10+ 64-bit machines as seen [here](https://github.com/dotnet/core/blob/main/release-notes/8.0/supported-os.md). If that does not apply to you, then make sure you've extracted the executable from the ZIP file before you've launched it, resolved your anti-virus, and have tried downloading the .NET Desktop Runtime mentioned under [installation instructions](https://github.com/FroggMaster/CreamInstaller#installation) above and restarting your computer. If none of the above work, then I simply cannot do anything about it, I do not control .NET. Either your system is not supported by the current version of .NET, or something is wrong/corrupted with your system.
|
||||
### The program won't launch
|
||||
|
||||
**Q:** The game I installed the unlocker(s) to is not working/the DLCs are not unlocked.
|
||||
**A:** Make sure you've read the note under [Usage](https://github.com/FroggMaster/CreamInstaller#usage) above! Assuming the program functioned as it was supposed to by properly installing DLC unlockers to your chosen games, this is not an issue I can do anything about and it's entirely up to you to seek the appropriate resources to fix it yourself (hint: https://cs.rin.ru/forum/viewforum.php?f=10).
|
||||
Check the following in order:
|
||||
|
||||
1. **System requirements**: Windows 10+ 64-bit only ([.NET 8 Supported OS List](https://github.com/dotnet/core/blob/main/release-notes/8.0/supported-os.md))
|
||||
2. **Extract before running**: Ensure you've extracted the executable from the ZIP file
|
||||
3. **Antivirus**: Add an exception for CreamInstaller (see [False Positives](#false-positive-antivirus-detections) below)
|
||||
4. **Runtime**: Install [.NET 8 Desktop Runtime](https://github.com/FroggMaster/CreamInstaller#installation) and restart your computer
|
||||
|
||||
If none of these work, your system may not support .NET 8 or may have underlying system issues.
|
||||
|
||||
---
|
||||
|
||||
### DLCs aren't unlocking in my game
|
||||
|
||||
CreamInstaller only installs DLC **unlockers** — it does **not** guarantee they will work for every game.
|
||||
|
||||
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.
|
||||
|
||||
**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
|
||||
|
||||
|
||||
---
|
||||
|
||||
### My antivirus detects CreamInstaller as a virus (False Positives)
|
||||
|
||||
**These are false positives.** See the detailed explanation below:
|
||||
<details>
|
||||
<summary>Click to expand for information about false positives</summary>
|
||||
|
||||
## Why Antivirus Software Flags CreamInstaller
|
||||
|
||||
CreamInstaller is **not a virus**, but it's commonly flagged because of its functionality:
|
||||
|
||||
| Reason | Explanation |
|
||||
|--------|-------------|
|
||||
| **DLL modification** | Replaces game DLLs to unlock content — behavior similar to some malware |
|
||||
| **Process hooking** | Embedded DLC unlockers interact with Steam/Epic/Ubisoft/game processes |
|
||||
| **Compressed executable** | Single-file executables are often associated with packed malware |
|
||||
| **Not code-signed** | No Extended Validation certificate ($300–500/year) means lower AV reputation (**I will not be paying for this**) |
|
||||
| **Misc** | Game modding tools frequently trigger heuristic detections regardless of intent |
|
||||
|
||||
## Common False Positive Names
|
||||
|
||||
| Detection Name | What It Usually Means / Why It’s a False Positive |
|
||||
|----------------------------------------|---------------------------------------------------|
|
||||
| Mamson.A!ac | Generic heuristic detection; often triggered by packed or obfuscated executables |
|
||||
| Phonzy.A!ml | Machine-learning detection; flags unusual behavior patterns |
|
||||
| Wacatac.H!ml | Extremely common false positive; triggered by compressed or self-updating programs |
|
||||
| Malgent!MSR | Generic Microsoft label for “suspicious behavior,” not confirmed malware |
|
||||
| Tiggre!rfn | Heuristic runtime detection often seen with tools that hook processes |
|
||||
| UDS:DangerousObject.Multi.Generic | Reputation-based detection for tools that *can* be abused |
|
||||
| Trojan.Win64.Agent | Very broad category; common false positive for unsigned binaries |
|
||||
| Trojan.Win64.Agent.oa!s1 | Cloud/AI heuristic variant of the above |
|
||||
|
||||
**See also:** [Archived issue #40](https://web.archive.org/web/20240604162435/https://github.com/pointfeev/CreamInstaller/issues/40)
|
||||
|
||||
## Verify Safety Yourself
|
||||
|
||||
CreamInstaller is **100% open source**:
|
||||
|
||||
1. **Review the source code** in this repository
|
||||
2. **Build it yourself**
|
||||
3. **Compare hashes** of your build with the official release
|
||||
|
||||
</details>
|
||||
|
||||
**Q:** The program and/or files installed by the program are detected as a virus/trojan/malware.
|
||||
**A:** The "issue" of the program's outputted Koaloader DLLs being detected as false positives such as Mamson.A!ac, Phonzy.A!ml, Wacatac.H!ml, Malgent!MSR, Tiggre!rfn, and many many others, has already been posted and explained dozens of times now in many different manners... please do not post it again, you will just be ignored; instead, refer to the explanations within issue #40 and its linked issues: [WebArchived Link: https://github.com/pointfeev/CreamInstaller/issues/40](https://web.archive.org/web/20240604162435/https://github.com/pointfeev/CreamInstaller/issues/40).
|
||||
|
||||
---
|
||||
##### Bugs/Crashes/Issues:
|
||||
@@ -75,6 +139,7 @@ For reliable and quick assistance, all bugs, crashes and other issues should be
|
||||
##### **HOWEVER**: Please read the [FAQ entry](https://github.com/FroggMaster/CreamInstaller#faq--common-issues) above and/or [template issue](https://github.com/FroggMaster/CreamInstaller/issues/new/choose) corresponding to your problem should one exist! Also, note that the [GitHub Issues](https://github.com/FroggMaster/CreamInstaller/issues) page is not your personal assistance hotline, rather it is for genuine bugs/crashes/issues with the program itself. If you post an issue which is off-topic or has already been explained within the FAQ, template issues, and/or within this text in general, I will just close it and you will be ignored.
|
||||
|
||||
---
|
||||
|
||||
##### More Information:
|
||||
* SteamCMD installation and appinfo cache can be found at **C:\ProgramData\CreamInstaller**.
|
||||
* The program automatically and very quickly updates from [GitHub](https://github.com/FroggMaster/CreamInstaller) by choice of the user through a dialog on startup.
|
||||
|
||||
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 36 KiB After Width: | Height: | Size: 39 KiB |
Reference in New Issue
Block a user