76 Commits

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

Related Work Items: #2, #21
2026-06-01 00:01:59 -07:00
Frog 54592230c3 Increment App Version
- Increase app version to 5.0.2.3
2026-05-27 01:33:35 -07:00
Frog 34cb3b862c Fix Light Mode Selection Bar
- Adjusts the selection bar so it properly selects the whole field.
- Adjusted the color of the selection bar to be a lighter blue so text is easier to read.
2026-05-27 01:32:24 -07:00
Frog 8040e6bcdb Fix Dark Mode Expander / Selection Bar
- Fixes missing expander in dark mode, which occured when I was fixing the checkboxes. (Woops)
- Fixes the selection bar in dark mode, now properly selects the proxy button as well instead of being cut off. (Light mode still looks like shit though.)
2026-05-27 01:28:28 -07:00
Frog 593f396c54 Add Test Game Generator for Steam and Epic
- Added Test Game Generator to the Debug window for creating fake installed game entries for testing
- Updated ThemeManager to properly theme ListBox controls in dark mode
- Labels now use transparent backgrounds for better appearance
2026-05-27 01:20:03 -07:00
Frog 2f993bfe3b Updated Preview Image
- Updated preview image to Dark Mode
2026-05-26 02:41:03 -07:00
Frog e9f8222d8e Increment App Version
- Increase app version to 5.0.2.2
2026-05-26 02:29:48 -07:00
Frog 68842aad9f Logging Infrastructure / Normalize Paths
- Added base logging infrastructure
- Create scan log during library/game scan in %ProgramData%\CreamInstaller\scan.log
- Normalize Steam library paths (libraryfolders.vdf) using Path.GetFullPath + ResolvePath to handle slashes, casing, and drive changes
- Diagnostics.ResolvePath: wrap GetFileSystemInfos in try/catch and guard against empty results to prevent IndexOutOfRangeException (May assist with issues on slow or intermittently accessible external drives)
2026-05-26 02:11:15 -07:00
Frog 1d5dc4ac8c Increment Version
- Increase version to 5.0.2.1
2026-05-25 16:18:01 -07:00
Frog 31ca8a947f Fix: CustomTreeView Checkbox Themes
- Further adjust the Proxy/CustomTreeView checkbox theme, doesn't perfectly match the top settings checkboxes, but it's good enough.
2026-05-25 14:57:38 -07:00
Frog 558612f098 fix: dark mode checkboxes in CustomTreeView
- Added ThemeManager.DrawDarkCheckBox to render dark-themed rounded checkboxes
- Fixed TreeView and Proxy checkboxes showing white backgrounds in dark mode due to CheckBoxRenderer drawing opaque system-themed glyphs. > Still need to fix the actual checkmark to match the top checkboxes.
- Disabled DrawDefault for checkbox tree nodes in dark mode and manually draw the row background, text, and checkbox glyphs.
- Checkbox glyph sizing now uses CheckBoxRenderer.GetGlyphSize for proper DPI scaling. (Probably)
2026-05-25 14:54:51 -07:00
Frog b7067c2621 Change Game Filter Text to PlaceHolder Text
- Removed the game search label in favor of textbox placeholder text.
 - Added methods to ensure the placeholder text renders properly
- Added inline comments to explain what NativeMethods is for
2026-05-25 02:35:50 -07:00
Frog fe55efc072 Added Game Search / Filter When Scanning Games Closes #15
- Added a "Game search:" label and text box above the game list in
  SelectDialogForm, allowing users to filter the program/game list
  by name in real-time (case-insensitive substring match).
- Added TextBox theming support to ThemeManager for both dark and
  light modes, ensuring the new search field conforms to the active
  theme.
2026-05-25 02:18:56 -07:00
Frog 39097c27ef Add Dev CI Builds
- Added CI action workflow for dev branch
2026-03-23 23:55:06 -07:00
Frog 3ba4747be3 Increment Version 5.0.2.0
- Increment version number to 5.0.2.0
2026-03-17 11:24:03 -07:00
Frog 322490d0b2 Additional Changes to Correct Null Exception for #12
- Additional changes to prevent Null Exception when catalog mapping property is null.
2026-03-16 22:58:00 -07:00
Frog 3dae7508f0 Merge branch 'main' of https://github.com/FroggMaster/CreamInstaller 2026-03-15 03:15:25 -07:00
Frog 8f8e893e84 Fix NullReferenceException in EpicStore See #12
- Fix a NullReferenceException that could occur when the Epic GraphQL API returns a partial response with missing fields (Data, Catalog, SearchStore, or CatalogOffers).
2026-03-15 03:15:17 -07:00
Frog 0cec730c1e Update README.md
- Additional clarification about DLC files because people apparently can't fucking read.
2026-02-10 12:15:52 -08:00
Frog df7dc0e019 Update create-release.yml
- Added generate_release_notes > Should automatically add the full change-log in the release description.
2026-01-31 03:09:58 -08:00
Frog 455a290051 Increment Application Version
- Version increased to 5.0.1.9
2026-01-31 03:03:13 -08:00
Frog 6e8326b84f Merge branch 'main' of https://github.com/FroggMaster/CreamInstaller 2026-01-31 03:00:44 -08:00
Frog f20ca0d833 Migrate Legacy Theme Code into Theme Manager / Shitty Fix for Selection in Dark Mode / Shitty Comments
- Moved as much of the legacy theme code as I could find into ThemeManager
- Some shitty comments added VIA AI (probably better than I'd write anyways)
- Some adjustments to how the highlight is being rendered, for some fucking reason the system highlight refuses to match on the left/right (probably some dumb shit I'm doing.) > This version makes things clearer/easier to read in dark mode.
2026-01-31 03:00:38 -08:00
Frog 788e7f5293 Update FAQ
- Updated FAQ so particular questions have headings which allows them to be directly linked to.
2026-01-31 00:40:37 -08:00
Frog ce566cfa47 Update README.md 2026-01-30 21:59:39 -08:00
Frog d2a5549878 Dark Mode Additions / Proxy Combo Box
- Moves more color handling to ThemeManager
- Adds dark mode for the proxy combo box
- Adjusts the combo box highlight so its no fucking impossible to read in dark mode.
2026-01-30 01:49:01 -08:00
Frog e79aecc023 Fixes bug with toggling dark mode
- Fixes issue with toggling dark mode after having added a game to the list, the store identifier / proxy toggle would not properly change colors.
2026-01-30 01:04:09 -08:00
Frog 4b9897bde2 Dark Mode For Right Click Context Menu
- Adds dark mode for the right click context menu. (Overlooked this and a few other items when adding dark mode.)
2026-01-30 00:39:19 -08:00
Frog 034951e4d2 Right Click Context Fix
- Should fix the issue with the right click menu displaying incorrectly at times when clicking on it a little too quickly.
2026-01-30 00:15:42 -08:00
Frog 4075078790 Updated ReadMe
- Updated common false positives and include a rough description of what they mean. 
- Removed link to build instructions (spoilers, I don't have any written.)
2026-01-28 16:50:23 -08:00
Frog 46df791c19 ReadMe Updates
- Clarified FAQ
- Added additional information about false positives, if you ask about this you can RTFM. 
- Added unnecessary legal disclaimer.
2026-01-26 17:04:53 -08:00
Frog b26a5aec48 Adjusted bug template
Adjusted organization of bug template
2026-01-26 16:31:40 -08:00
Frog e824ebd713 Bug report template changes
Updated bug report template to improve clarity and structure.
2026-01-26 16:26:29 -08:00
Frog d723d1c0c7 Increment Application Version 2026-01-26 01:41:42 -08:00
Frog 956b6d0c1c Fixes Incorrect Config Syntax / Fix #7
- Fixes incorrect comma that was added at the end of a configuration if Extra DLCs were added
2026-01-25 23:33:16 -08:00
Frog 028dd1586b Increment Application Version
- Increment app version to 5.0.1.7
2026-01-18 13:04:40 -08:00
Frog f5d6007404 Sory By Name By Default
- Adjusts the Sort By Name setting to be enabled by default for a more user friendly experience.
2026-01-18 13:02:44 -08:00
Frog 1c7ffb215d BugFix for DLC Query Failure - Fixes #5
- Fixes #5 Corrects null pointer failure. This ensures publishers exist AND that it contains at least one object before we access it.
2026-01-18 12:47:03 -08:00
Frog 1bd5501869 Update SmokeAPI to v4.1.3
- Updated SmokeAPI to v4.1.3
- Included MD5 hash for older versions (V4.1.0, V4.1.1, v4.1.2)
2026-01-18 12:33:25 -08:00
Frog 1db70541f9 Increment App Version 2025-11-21 03:01:44 -08:00
Frog ae08e990cc Enable Dark Mode by Default
- Enabled dark mode by default (Looks decent enough)
2025-11-21 02:52:01 -08:00
Frog 12c7c9a9d2 Change Dark Mode Toggle to Checkbox
- Changed dark mode toggle to Checkbox instead of button > better aligns with other settings in the top panel. (Gee, sure is getting crowded up there)
2025-11-21 02:48:19 -08:00
Frog 701ca5627d Merge branch 'main' of https://github.com/FroggMaster/CreamInstaller 2025-11-21 02:19:40 -08:00
Frog c0af3b85bb Dark Mode / Hyperlink Color Fix
- Fixes hard to read hyperlinks in Dark Mode
2025-11-21 02:19:18 -08:00
Frog 8577e6df7f Update README.md
- Correct typo/extra word.
2025-11-20 23:03:26 -08:00
Frog 961b7153f4 Added Info about CI Builds to ReadMe / Added Status Badge for CI Builds / Added Status Badge for Latest Release
-  Added Info about CI Builds to ReadMe 
- Added Status Badge for CI Builds 
- Added Status Badge for Latest Release
2025-11-20 02:55:35 -08:00
Frog e640b8b15d Dark mode WIP
- The start of a dark mode theme
- Adds a button to toggle dark mode in the top options panel. (The buttons kind of ugly though and I may change this to a checkbox to align with the other existing options)
- Added library uxtheme.dll for handling scrollbar themeing (Dealing with scrollbars sucks)
- Added ThemeManager for handling of theme colors (Allows for potential expandability I'll probably never implement)
2025-11-20 02:35:51 -08:00
Frog bcf3ff84fe Merge branch 'main' of https://github.com/FroggMaster/CreamInstaller 2025-11-19 22:58:40 -08:00
Frog eb1fee38f3 Slight cleaner preview image, that also shows an enabled proxy dll. 2025-11-19 22:58:25 -08:00
Frog cffc4cce07 Update ci-builds.yml
Bug Fix: I forgot to adjust the Rename-Item PS command to rename the EXE before packaging.
2025-11-19 22:04:24 -08:00
Frog b7a9505599 Adjusted CI Build Workflow
- Adjusted artifact name to include CI-Release
- Adjusted EXE to include CI
2025-11-19 03:36:36 -08:00
Frog 094d60b003 Merge branch 'main' of https://github.com/FroggMaster/CreamInstaller 2025-11-19 03:32:11 -08:00
Frog 09cafa27fb Update ci-builds.yml
- Fix artifact becoming two ZIPs (LOL woops)
2025-11-19 03:30:34 -08:00
Frog e6fa7b4a39 Fixed unreachable logging segment for CreamAPI
- Fixed unreachable code that provides information about deleted config file
- Adjusted old comment from pointfeev that indiciated SmokeAPI might always be false (it's now toggleable)
2025-11-19 03:27:28 -08:00
Frog 8a24bdad81 Update ci-builds.yml
- GH Action fix
2025-11-19 03:17:26 -08:00
Frog 21bcfae688 Update README.md
- Minor change to ReadMe changing "extract" to "move"
2025-11-19 03:15:02 -08:00
Frog 4063e482dd Add zip release
- Add zip release for internal application updates
- Left the EXE for ease of access for those that don't want to extract a ZIP on first install.
2025-11-19 03:00:47 -08:00
Frog bdb1d9ffd2 Update ci-builds.yml
- Fix to include git commit in final CI build
2025-11-19 02:59:44 -08:00
Frog 800cb2b9f6 Rename CI-Builds / Adjust so CI Builds include commit in ZIP file
- Updated name of CI builds for clarity to include -
- Updated so the final ZIP file includes the commit it was built from
- Adjusted artifact release name.
2025-11-19 02:53:29 -08:00
Frog 8b6013e1c0 Update and rename autobuild.yml to cibuilds.yml
- Adjusted old AutoBuild script for CI builds
- Renamed from AutoBuild to CIBuilds
- Configured to run on every commit to main branch
2025-11-19 02:22:53 -08:00
Frog 668f367838 Update preview image 2025-11-19 02:16:32 -08:00
Frog 6613b777a7 Increment App version / Adjust Copyright Info
- Increment App Version
- Adjust Copyright Info / Repository
2025-11-19 02:01:17 -08:00
Frog 1036f8a8b4 Apply SelectForm.cs from commit 6fa5503 2025-11-18 02:03:11 -08:00
Frog c5a3a98827 Update README.md
- Adjusted ReadMe instructions to reflect EXE download rather than ZIP
- Updated link to Issue 40 > Now points to Web Archived version of the original issue before being hit by DCMA
2025-11-18 01:51:03 -08:00
Frog 4751a3bf76 Update README.md
- Fixed ReadMe change from previous merge (I really should pay a little more attention when pulling from a fork, I guess that's what I get for doing things at 2AM)
2025-11-18 01:42:44 -08:00
Frog de43eb9561 Increment application version
- Increment application version to 5.0.1.4
2025-11-14 00:51:40 -08:00
Frog 6931e43874 Adjusted Desinger So it Loads DPI Unaware / Fixed Options Panel UI
- This should avoid the UI from cutting off on larger DPI systems.
2025-11-14 00:51:05 -08:00
Frog 8958c0626f Correct Repo Name Again / Adjust hard to understand help text for SmokeAPI
- Corrected Repository owner name again, missed this during the merge (Unintended change pointing to the wrong repository)
- Adjusted some hard to understand text about the experimental warning for SmokeAPI usage
2025-11-14 00:02:19 -08:00
42 changed files with 3404 additions and 739 deletions
+34 -4
View File
@@ -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]
@@ -1,12 +1,10 @@
name: Autobuild
name: CI Builds
on:
push:
tags:
- '*'
branches:
- main
workflow_dispatch:
pull_request:
types: [ main ]
jobs:
build:
@@ -30,8 +28,20 @@ jobs:
- 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
id: vars
run: |
$shortSha = $env:GITHUB_SHA.Substring(0,7)
Write-Output "shortSha=$shortSha" >> $env:GITHUB_ENV
shell: pwsh
- name: Rename EXE with short commit SHA
run: |
Rename-Item -Path ./publish/CreamInstaller.exe -NewName "CreamInstaller-CI-$env:shortSha.exe"
shell: pwsh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: CreamInstaller-release
path: ./publish/CreamInstaller.exe
name: CreamInstaller-CI-Release-${{ env.shortSha }}
path: ./publish/CreamInstaller-CI-${{ env.shortSha }}.exe
+8 -1
View File
@@ -52,12 +52,19 @@ jobs:
- name: Publish single-file
run: dotnet publish CreamInstaller.sln -c Release -r win-x64 -p:PublishSingleFile=true --self-contained true --output ./publish
- name: Zip Release
run: |
Compress-Archive -Path ./publish/* -DestinationPath ./publish/CreamInstaller.zip
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
with:
tag_name: v${{ inputs.version }}
name: ${{ inputs.title || format('Release v{0}', inputs.version) }}
body: ${{ inputs.notes }}
files: ./publish/CreamInstaller.exe
generate_release_notes: true
files: |
./publish/CreamInstaller.exe
./publish/CreamInstaller.zip
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+49
View File
@@ -0,0 +1,49 @@
name: Dev CI Builds
on:
push:
branches:
- dev
workflow_dispatch:
jobs:
build:
runs-on: windows-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.0.x'
- name: Restore dependencies
run: dotnet restore CreamInstaller.sln
- name: Build Release
run: dotnet build CreamInstaller.sln --configuration Release --no-restore
- name: Publish single-file
run: dotnet publish CreamInstaller.sln -c Release -r win-x64 -p:PublishSingleFile=true --self-contained true --output ./publish
- name: Set short commit SHA and branch name
id: vars
run: |
$shortSha = $env:GITHUB_SHA.Substring(0,7)
$branch = $env:GITHUB_REF_NAME
Write-Output "shortSha=$shortSha" >> $env:GITHUB_ENV
Write-Output "branch=$branch" >> $env:GITHUB_ENV
shell: pwsh
- name: Rename EXE with branch and short commit SHA
run: |
Rename-Item -Path ./publish/CreamInstaller.exe -NewName "CreamInstaller-CI-$env:branch-$env:shortSha.exe"
shell: pwsh
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: CreamInstaller-CI-${{ env.branch }}-${{ env.shortSha }}
path: ./publish/CreamInstaller-CI-${{ env.branch }}-${{ env.shortSha }}.exe
+5
View File
@@ -335,3 +335,8 @@ ASALocalRun/
.localhistory/
*.exe
*.zip
# OpenCode / AI Agents and Tools
.opencode/
.sisyphus/
logs/
+67 -77
View File
@@ -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)
{
+12
View File
@@ -42,6 +42,18 @@ internal class CustomForm : Form
}
}
protected override void OnHandleCreated(EventArgs e)
{
base.OnHandleCreated(e);
ThemeManager.Apply(this); // apply current theme (initial or toggled)
}
protected override void OnShown(EventArgs e)
{
base.OnShown(e);
ThemeManager.Apply(this); // ensure late-added controls also themed
}
private void OnHelpButtonClicked(object sender, EventArgs args)
{
using DialogForm helpDialog = new(this);
+281 -51
View File
@@ -14,28 +14,28 @@ 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;
internal CustomTreeView()
{
DrawMode = TreeViewDrawMode.OwnerDrawAll;
ShowNodeToolTips = true;
DrawMode = TreeViewDrawMode.OwnerDrawAll;
Invalidated += OnInvalidated;
DrawNode += DrawTreeNode;
Disposed += OnDisposed;
@@ -54,6 +54,8 @@ internal sealed class CustomTreeView : TreeView
{
backBrush?.Dispose();
backBrush = null;
selectionBrush?.Dispose();
selectionBrush = null;
comboBoxFont?.Dispose();
comboBoxFont = null;
comboBoxDropDown?.Dispose();
@@ -63,26 +65,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,17 +209,26 @@ 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)
{
SelectionDLC dlc = SelectionDLC.FromId(dlcType, node.Parent?.Name, id);
text = dlc?.Selection != null ? dlc.Selection.Platform.ToString() : dlcType.ToString();
}
else text = platform.ToString();
else
{
text = platform.ToString();
if (platform is Platform.Steam)
{
Selection selection = Selection.FromId(platform, id);
if (selection is not null && selection.SteamApiDllMissing)
text = "Proxy Only";
}
}
Size size = TextRenderer.MeasureText(graphics, text, font);
bounds = bounds with { X = bounds.X + bounds.Width, Width = size.Width };
@@ -115,10 +240,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 +267,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 +374,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 +386,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 +399,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 +410,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 +454,7 @@ internal sealed class CustomTreeView : TreeView
comboBoxDropDown ??= new();
comboBoxDropDown.ShowItemToolTips = false;
comboBoxDropDown.Items.Clear();
foreach (string proxy in proxies)
{
bool canUse = true;
@@ -261,13 +470,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;
}
@@ -277,9 +495,21 @@ internal sealed class CustomTreeView : TreeView
_ = checkBoxBounds.Remove(pair.Key);
else if (pair.Value.Contains(clickPoint))
{
if (pair.Key.SteamApiDllMissing)
return;
pair.Key.UseProxy = !pair.Key.UseProxy;
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 -3
View File
@@ -4,8 +4,8 @@
<TargetFramework>net8.0-windows10.0.22621.0</TargetFramework>
<UseWindowsForms>True</UseWindowsForms>
<ApplicationIcon>Resources\program.ico</ApplicationIcon>
<Version>5.0.1.3</Version>
<Copyright>2021, pointfeev (https://github.com/pointfeev)</Copyright>
<Version>5.0.2.3</Version>
<Copyright>2026, FroggMaster (https://github.com/FroggMaster)</Copyright>
<Company>CreamInstaller</Company>
<Product>Automatic DLC Unlocker Installer &amp; Configuration Generator</Product>
<StartupObject>CreamInstaller.Program</StartupObject>
@@ -24,6 +24,7 @@
<AllowUnsafeBlocks>True</AllowUnsafeBlocks>
<SupportedOSPlatformVersion>10.0.17763.0</SupportedOSPlatformVersion>
<Platforms>x64</Platforms>
<ForceDesignerDpiUnaware>true</ForceDesignerDpiUnaware>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)'=='Release'">
<AssemblyName>$(Company)</AssemblyName>
@@ -207,4 +208,4 @@
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
</Project>
</Project>
+20 -3
View File
@@ -32,16 +32,31 @@ partial class DebugForm
private void InitializeComponent()
{
debugTextBox = new RichTextBox();
testGameButton = new Button();
SuspendLayout();
//
// testGameButton
//
testGameButton.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
testGameButton.AutoSize = true;
testGameButton.AutoSizeMode = AutoSizeMode.GrowAndShrink;
testGameButton.Location = new System.Drawing.Point(10, 10);
testGameButton.Name = "testGameButton";
testGameButton.Padding = new Padding(3, 0, 3, 0);
testGameButton.Size = new System.Drawing.Size(540, 25);
testGameButton.TabIndex = 1;
testGameButton.Text = "Test Game";
testGameButton.UseVisualStyleBackColor = true;
testGameButton.Click += OnTestGame;
//
// debugTextBox
//
debugTextBox.Dock = DockStyle.Fill;
debugTextBox.Location = new System.Drawing.Point(10, 10);
debugTextBox.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
debugTextBox.Location = new System.Drawing.Point(10, 41);
debugTextBox.Name = "debugTextBox";
debugTextBox.ReadOnly = true;
debugTextBox.ScrollBars = RichTextBoxScrollBars.ForcedBoth;
debugTextBox.Size = new System.Drawing.Size(540, 317);
debugTextBox.Size = new System.Drawing.Size(540, 286);
debugTextBox.TabIndex = 0;
debugTextBox.TabStop = false;
debugTextBox.Text = "";
@@ -52,6 +67,7 @@ partial class DebugForm
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new System.Drawing.Size(560, 337);
ControlBox = false;
Controls.Add(testGameButton);
Controls.Add(debugTextBox);
FormBorderStyle = FormBorderStyle.FixedSingle;
MaximizeBox = false;
@@ -68,4 +84,5 @@ partial class DebugForm
#endregion
private RichTextBox debugTextBox;
private Button testGameButton;
}
+24 -3
View File
@@ -9,6 +9,9 @@ namespace CreamInstaller.Forms;
internal sealed partial class DebugForm : CustomForm
{
private static DebugForm current;
private static readonly object currentLock = new();
internal static bool IsOpen { get; private set; }
private Form attachedForm;
@@ -22,9 +25,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;
}
}
}
@@ -56,6 +64,13 @@ internal sealed partial class DebugForm : CustomForm
attachedForm.SizeChanged += OnChange;
attachedForm.VisibleChanged += OnChange;
UpdateAttachment();
if (!IsOpen)
{
IsOpen = true;
ProgramData.OnLogWarning += msg => Log(msg, LogTextBox.Warning);
ProgramData.OnLogError += msg => Log(msg, LogTextBox.Error);
}
}
private void OnChange(object sender, EventArgs args) => UpdateAttachment();
@@ -81,4 +96,10 @@ internal sealed partial class DebugForm : CustomForm
debugTextBox.AppendText(text, color, true);
});
}
private void OnTestGame(object sender, EventArgs e)
{
using TestGameForm form = new(this);
_ = form.ShowDialog(this);
}
}
+55 -17
View File
@@ -9,7 +9,6 @@ using CreamInstaller.Resources;
using CreamInstaller.Utility;
using static CreamInstaller.Platforms.Paradox.ParadoxLauncher;
using static CreamInstaller.Resources.Resources;
namespace CreamInstaller.Forms;
internal sealed partial class InstallForm : CustomForm
@@ -351,6 +350,41 @@ internal sealed partial class InstallForm : CustomForm
++completeOperationsCount;
}
// Persist install/uninstall results
foreach (Selection selection in Selection.AllEnabled)
{
if (uninstalling)
{
selection.InstalledUnlocker = InstalledUnlocker.None;
ProgramData.RemoveInstalledGame(selection.Platform, selection.Id);
}
else
{
InstalledUnlocker unlocker = selection.DetectInstalledUnlocker();
selection.InstalledUnlocker = unlocker;
if (unlocker != InstalledUnlocker.None)
ProgramData.UpsertInstalledGame(new InstalledGameRecord
{
Platform = selection.Platform,
Id = selection.Id,
Name = selection.Name,
RootDirectory = selection.RootDirectory,
Unlocker = unlocker,
UseProxy = selection.UseProxy,
Proxy = selection.Proxy,
UseExtraProtection = selection.UseExtraProtection,
Dlc = selection.DLC.Select(dlc => new InstalledDlcRecord
{
DlcType = dlc.Type.ToString(),
Id = dlc.Id,
Name = dlc.Name
}).ToList()
});
}
}
SelectForm.Current?.Invoke(() => SelectForm.Current?.InvalidateGameList());
Program.Cleanup();
int activeCount = activeSelections.Count;
if (activeCount > 0)
@@ -392,25 +426,29 @@ internal sealed partial class InstallForm : CustomForm
private void OnLoad(object sender, EventArgs a)
{
retry:
try
bool retry = true;
while (retry)
{
userInfoLabel.Text = "Loading . . . ";
logTextBox.Text = string.Empty;
selectionCount = 0;
foreach (Selection selection in Selection.AllEnabled)
try
{
selectionCount++;
_ = activeSelections.Add(selection);
}
userInfoLabel.Text = "Loading . . . ";
logTextBox.Text = string.Empty;
selectionCount = 0;
foreach (Selection selection in Selection.AllEnabled)
{
selectionCount++;
_ = activeSelections.Add(selection);
}
Start();
}
catch (Exception e)
{
if (e.HandleException(this))
goto retry;
Close();
Start();
retry = false;
}
catch (Exception e)
{
retry = e.HandleException(this);
if (!retry)
Close();
}
}
}
+16 -2
View File
@@ -33,6 +33,7 @@ namespace CreamInstaller.Forms
saveButton = new Button();
uninstallAllButton = new Button();
selectionTreeView = new CustomTreeView();
filterTextBox = new System.Windows.Forms.TextBox();
groupBox.SuspendLayout();
allCheckBoxFlowPanel.SuspendLayout();
SuspendLayout();
@@ -51,15 +52,25 @@ namespace CreamInstaller.Forms
acceptButton.Text = "OK";
acceptButton.UseVisualStyleBackColor = true;
//
// filterTextBox
//
filterTextBox.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
filterTextBox.Location = new System.Drawing.Point(12, 14);
filterTextBox.Name = "filterTextBox";
filterTextBox.PlaceholderText = "Enter the name of a game to search";
filterTextBox.Size = new System.Drawing.Size(524, 23);
filterTextBox.TabIndex = 0;
filterTextBox.TextChanged += OnFilterTextChanged;
//
// groupBox
//
groupBox.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
groupBox.Controls.Add(selectionTreeView);
groupBox.Controls.Add(allCheckBoxFlowPanel);
groupBox.Location = new System.Drawing.Point(12, 12);
groupBox.Location = new System.Drawing.Point(12, 43);
groupBox.MinimumSize = new System.Drawing.Size(240, 40);
groupBox.Name = "groupBox";
groupBox.Size = new System.Drawing.Size(524, 225);
groupBox.Size = new System.Drawing.Size(524, 194);
groupBox.TabIndex = 3;
groupBox.TabStop = false;
groupBox.Text = "Choices";
@@ -106,6 +117,7 @@ namespace CreamInstaller.Forms
//
sortCheckBox.Anchor = AnchorStyles.Bottom | AnchorStyles.Right;
sortCheckBox.AutoSize = true;
sortCheckBox.Checked = true; // Enable Sort By Name by default
sortCheckBox.Location = new System.Drawing.Point(220, 247);
sortCheckBox.Margin = new Padding(3, 0, 0, 0);
sortCheckBox.Name = "sortCheckBox";
@@ -187,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;
@@ -214,5 +227,6 @@ namespace CreamInstaller.Forms
private Button saveButton;
private CheckBox sortCheckBox;
private Button uninstallAllButton;
private System.Windows.Forms.TextBox filterTextBox;
}
}
+34 -7
View File
@@ -10,11 +10,12 @@ 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)
{
InitializeComponent();
selectionTreeView.TreeViewNodeSorter = PlatformIdComparer.NodeName;
selectionTreeView.TreeViewNodeSorter = sortCheckBox.Checked ? PlatformIdComparer.NodeText : PlatformIdComparer.NodeName;
}
internal DialogResult QueryUser(string groupBoxText,
@@ -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
+71 -23
View File
@@ -31,6 +31,8 @@ namespace CreamInstaller.Forms
useSmokeAPILayoutPanel = new FlowLayoutPanel();
useSmokeAPICheckBox = new CheckBox();
useSmokeAPIHelpButton = new Button();
darkModeFlowPanel = new FlowLayoutPanel();
darkModeCheckBox = new CheckBox();
allCheckBoxLayoutPanel = new FlowLayoutPanel();
allCheckBox = new CheckBox();
progressBar = new ProgressBar();
@@ -45,10 +47,12 @@ namespace CreamInstaller.Forms
resetButton = new Button();
saveFlowPanel = new FlowLayoutPanel();
selectionTreeView = new CustomTreeView();
topOptionsTable = new TableLayoutPanel();
programsGroupBox.SuspendLayout();
proxyFlowPanel.SuspendLayout();
blockedGamesFlowPanel.SuspendLayout();
useSmokeAPILayoutPanel.SuspendLayout();
darkModeFlowPanel.SuspendLayout();
allCheckBoxLayoutPanel.SuspendLayout();
saveFlowPanel.SuspendLayout();
SuspendLayout();
@@ -85,26 +89,20 @@ namespace CreamInstaller.Forms
// programsGroupBox
//
programsGroupBox.Anchor = AnchorStyles.Top | AnchorStyles.Bottom | AnchorStyles.Left | AnchorStyles.Right;
programsGroupBox.Controls.Add(proxyFlowPanel);
programsGroupBox.Controls.Add(noneFoundLabel);
programsGroupBox.Controls.Add(blockedGamesFlowPanel);
programsGroupBox.Controls.Add(useSmokeAPILayoutPanel);
programsGroupBox.Controls.Add(allCheckBoxLayoutPanel);
programsGroupBox.Controls.Add(selectionTreeView);
programsGroupBox.Location = new System.Drawing.Point(12, 12);
programsGroupBox.Location = new System.Drawing.Point(12, 47);
programsGroupBox.Name = "programsGroupBox";
programsGroupBox.Size = new System.Drawing.Size(610, 287);
programsGroupBox.Size = new System.Drawing.Size(610, 252);
programsGroupBox.TabIndex = 8;
programsGroupBox.TabStop = false;
programsGroupBox.Text = "Programs / Games";
//
// proxyFlowPanel
//
proxyFlowPanel.Anchor = AnchorStyles.Top | AnchorStyles.Right;
proxyFlowPanel.AutoSize = true;
proxyFlowPanel.AutoSizeMode = AutoSizeMode.GrowAndShrink;
proxyFlowPanel.Controls.Add(proxyAllCheckBox);
proxyFlowPanel.Location = new System.Drawing.Point(478, -1);
proxyFlowPanel.Margin = new Padding(0);
proxyFlowPanel.Name = "proxyFlowPanel";
proxyFlowPanel.Size = new System.Drawing.Size(75, 19);
@@ -113,7 +111,6 @@ namespace CreamInstaller.Forms
//
// proxyAllCheckBox
//
proxyAllCheckBox.Anchor = AnchorStyles.Top | AnchorStyles.Right;
proxyAllCheckBox.AutoSize = true;
proxyAllCheckBox.Enabled = false;
proxyAllCheckBox.Location = new System.Drawing.Point(2, 0);
@@ -129,7 +126,7 @@ namespace CreamInstaller.Forms
noneFoundLabel.Dock = DockStyle.Fill;
noneFoundLabel.Location = new System.Drawing.Point(3, 19);
noneFoundLabel.Name = "noneFoundLabel";
noneFoundLabel.Size = new System.Drawing.Size(604, 265);
noneFoundLabel.Size = new System.Drawing.Size(604, 230);
noneFoundLabel.TabIndex = 1002;
noneFoundLabel.Text = "No applicable programs nor games were found on your computer!";
noneFoundLabel.TextAlign = System.Drawing.ContentAlignment.MiddleCenter;
@@ -137,12 +134,10 @@ namespace CreamInstaller.Forms
//
// blockedGamesFlowPanel
//
blockedGamesFlowPanel.Anchor = AnchorStyles.Top;
blockedGamesFlowPanel.AutoSize = true;
blockedGamesFlowPanel.AutoSizeMode = AutoSizeMode.GrowAndShrink;
blockedGamesFlowPanel.Controls.Add(blockedGamesCheckBox);
blockedGamesFlowPanel.Controls.Add(blockProtectedHelpButton);
blockedGamesFlowPanel.Location = new System.Drawing.Point(150, -1);
blockedGamesFlowPanel.Margin = new Padding(0);
blockedGamesFlowPanel.Name = "blockedGamesFlowPanel";
blockedGamesFlowPanel.Size = new System.Drawing.Size(170, 19);
@@ -179,13 +174,11 @@ namespace CreamInstaller.Forms
//
// useSmokeAPILayoutPanel
//
useSmokeAPILayoutPanel.Anchor = AnchorStyles.Top;
useSmokeAPILayoutPanel.AutoSize = true;
useSmokeAPILayoutPanel.AutoSizeMode = AutoSizeMode.GrowAndShrink;
useSmokeAPILayoutPanel.Controls.Add(useSmokeAPICheckBox);
useSmokeAPILayoutPanel.Controls.Add(useSmokeAPIHelpButton);
useSmokeAPILayoutPanel.Location = new System.Drawing.Point(338, -1);
useSmokeAPILayoutPanel.Margin = new Padding(0);
useSmokeAPILayoutPanel.Margin = new Padding(12, 0, 0, 0);
useSmokeAPILayoutPanel.Name = "useSmokeAPILayoutPanel";
useSmokeAPILayoutPanel.Size = new System.Drawing.Size(124, 19);
useSmokeAPILayoutPanel.TabIndex = 1006;
@@ -219,14 +212,36 @@ namespace CreamInstaller.Forms
useSmokeAPIHelpButton.UseVisualStyleBackColor = true;
useSmokeAPIHelpButton.Click += OnUseSmokeAPIHelpButtonClicked;
//
// darkModeFlowPanel
//
darkModeFlowPanel.AutoSize = true;
darkModeFlowPanel.AutoSizeMode = AutoSizeMode.GrowAndShrink;
darkModeFlowPanel.Margin = new Padding(12, 0, 0, 0);
darkModeFlowPanel.Name = "darkModeFlowPanel";
darkModeFlowPanel.Size = new System.Drawing.Size(98, 19);
darkModeFlowPanel.TabIndex = 10011;
darkModeFlowPanel.WrapContents = false;
//
// darkModeCheckBox
//
darkModeCheckBox.AutoSize = true;
darkModeCheckBox.Enabled = true;
darkModeCheckBox.Location = new System.Drawing.Point(2, 0);
darkModeCheckBox.Margin = new Padding(2, 0, 0, 0);
darkModeCheckBox.Name = "darkModeCheckBox";
darkModeCheckBox.Size = new System.Drawing.Size(96, 19);
darkModeCheckBox.TabIndex = 1;
darkModeCheckBox.Text = "Enable Dark Mode";
darkModeCheckBox.UseVisualStyleBackColor = true;
darkModeCheckBox.CheckedChanged += OnDarkModeCheckBoxChanged;
darkModeFlowPanel.Controls.Add(darkModeCheckBox);
//
// allCheckBoxLayoutPanel
//
allCheckBoxLayoutPanel.Anchor = AnchorStyles.Top | AnchorStyles.Right;
allCheckBoxLayoutPanel.AutoSize = true;
allCheckBoxLayoutPanel.AutoSizeMode = AutoSizeMode.GrowAndShrink;
allCheckBoxLayoutPanel.Controls.Add(allCheckBox);
allCheckBoxLayoutPanel.Location = new System.Drawing.Point(562, -1);
allCheckBoxLayoutPanel.Margin = new Padding(0);
allCheckBoxLayoutPanel.Margin = new Padding(12, 0, 0, 0);
allCheckBoxLayoutPanel.Name = "allCheckBoxLayoutPanel";
allCheckBoxLayoutPanel.Size = new System.Drawing.Size(42, 19);
allCheckBoxLayoutPanel.TabIndex = 1007;
@@ -234,7 +249,6 @@ namespace CreamInstaller.Forms
//
// allCheckBox
//
allCheckBox.Anchor = AnchorStyles.Top | AnchorStyles.Right;
allCheckBox.AutoSize = true;
allCheckBox.Checked = true;
allCheckBox.CheckState = CheckState.Checked;
@@ -258,7 +272,7 @@ namespace CreamInstaller.Forms
selectionTreeView.FullRowSelect = true;
selectionTreeView.Location = new System.Drawing.Point(3, 19);
selectionTreeView.Name = "selectionTreeView";
selectionTreeView.Size = new System.Drawing.Size(604, 265);
selectionTreeView.Size = new System.Drawing.Size(604, 230);
selectionTreeView.TabIndex = 1001;
//
// progressBar
@@ -332,6 +346,7 @@ namespace CreamInstaller.Forms
//
sortCheckBox.Anchor = AnchorStyles.Bottom | AnchorStyles.Left;
sortCheckBox.AutoSize = true;
sortCheckBox.Checked = true; // Enable Sort By Name by default
sortCheckBox.Location = new System.Drawing.Point(84, 380);
sortCheckBox.Margin = new Padding(3, 0, 0, 0);
sortCheckBox.Name = "sortCheckBox";
@@ -399,12 +414,39 @@ namespace CreamInstaller.Forms
saveFlowPanel.TabIndex = 10008;
saveFlowPanel.WrapContents = false;
//
// topOptionsTable
//
topOptionsTable.Anchor = AnchorStyles.Top | AnchorStyles.Left | AnchorStyles.Right;
topOptionsTable.AutoSize = true;
topOptionsTable.AutoSizeMode = AutoSizeMode.GrowAndShrink;
topOptionsTable.ColumnCount = 6;
topOptionsTable.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
topOptionsTable.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
topOptionsTable.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
topOptionsTable.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100F)); // spacer
topOptionsTable.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
topOptionsTable.ColumnStyles.Add(new ColumnStyle(SizeType.AutoSize));
topOptionsTable.Location = new System.Drawing.Point(12, 12);
topOptionsTable.Margin = new Padding(0);
topOptionsTable.Name = "topOptionsTable";
topOptionsTable.RowCount = 1;
topOptionsTable.RowStyles.Add(new RowStyle(SizeType.AutoSize));
topOptionsTable.Size = new System.Drawing.Size(610, 25);
topOptionsTable.TabIndex = 10009;
topOptionsTable.Controls.Clear();
topOptionsTable.Controls.Add(blockedGamesFlowPanel, 0, 0);
topOptionsTable.Controls.Add(useSmokeAPILayoutPanel, 1, 0);
topOptionsTable.Controls.Add(darkModeFlowPanel, 2, 0);
topOptionsTable.Controls.Add(proxyFlowPanel, 4, 0);
topOptionsTable.Controls.Add(allCheckBoxLayoutPanel, 5, 0);
//
// SelectForm
//
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
AutoScaleMode = AutoScaleMode.Font;
AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F);
AutoScaleMode = AutoScaleMode.Dpi;
AutoSizeMode = AutoSizeMode.GrowAndShrink;
ClientSize = new System.Drawing.Size(634, 411);
Controls.Add(topOptionsTable);
Controls.Add(saveFlowPanel);
Controls.Add(sortCheckBox);
Controls.Add(progressLabelDLCs);
@@ -425,11 +467,14 @@ namespace CreamInstaller.Forms
Text = "SelectForm";
Load += OnLoad;
programsGroupBox.ResumeLayout(false);
programsGroupBox.PerformLayout();
proxyFlowPanel.ResumeLayout(false);
proxyFlowPanel.PerformLayout();
blockedGamesFlowPanel.ResumeLayout(false);
blockedGamesFlowPanel.PerformLayout();
useSmokeAPILayoutPanel.ResumeLayout(false);
useSmokeAPILayoutPanel.PerformLayout();
darkModeFlowPanel.ResumeLayout(false);
darkModeFlowPanel.PerformLayout();
allCheckBoxLayoutPanel.ResumeLayout(false);
allCheckBoxLayoutPanel.PerformLayout();
saveFlowPanel.ResumeLayout(false);
@@ -455,6 +500,7 @@ namespace CreamInstaller.Forms
private Button useSmokeAPIHelpButton;
private FlowLayoutPanel blockedGamesFlowPanel;
private FlowLayoutPanel useSmokeAPILayoutPanel;
private FlowLayoutPanel darkModeFlowPanel;
private FlowLayoutPanel allCheckBoxLayoutPanel;
private Button uninstallButton;
private Label progressLabelGames;
@@ -466,6 +512,8 @@ namespace CreamInstaller.Forms
private Button loadButton;
private Button resetButton;
private FlowLayoutPanel saveFlowPanel;
private TableLayoutPanel topOptionsTable;
private CheckBox darkModeCheckBox;
}
}
+247 -50
View File
@@ -25,6 +25,7 @@ internal sealed partial class SelectForm : CustomForm
private const string HelpButtonListPrefix = "\n • ";
private static SelectForm current;
private static readonly object currentLock = new();
private readonly ConcurrentDictionary<string, string> remainingDLCs = new();
@@ -35,7 +36,7 @@ internal sealed partial class SelectForm : CustomForm
private SelectForm()
{
InitializeComponent();
selectionTreeView.TreeViewNodeSorter = PlatformIdComparer.NodeName;
selectionTreeView.TreeViewNodeSorter = sortCheckBox.Checked ? PlatformIdComparer.NodeText : PlatformIdComparer.NodeName;
Text = Program.ApplicationName;
}
@@ -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;
}
}
}
@@ -183,11 +189,16 @@ internal sealed partial class SelectForm : CustomForm
return;
HashSet<string> dllDirectories =
await gameDirectory.GetDllDirectoriesFromGameDirectory(Platform.Steam);
if (dllDirectories is null)
bool steamApiDllMissing = dllDirectories is null;
if (steamApiDllMissing)
{
_ = Interlocked.Decrement(ref steamGamesToCheck);
RemoveFromRemainingGames(name);
return;
dllDirectories = [];
if (uninstallAll)
{
_ = Interlocked.Decrement(ref steamGamesToCheck);
RemoveFromRemainingGames(name);
return;
}
}
if (uninstallAll)
@@ -204,7 +215,7 @@ internal sealed partial class SelectForm : CustomForm
return;
StoreAppData storeAppData = await SteamStore.QueryStoreAPI(appId);
_ = Interlocked.Decrement(ref steamGamesToCheck);
CmdAppData cmdAppData = await WithTimeout(SteamCMD.GetAppInfo(appId, branch, buildId), 20000);
CmdAppData cmdAppData = await WithTimeout(SteamCMD.GetAppInfo(appId, branch, buildId), 16000);
if (storeAppData is null && cmdAppData is null)
{
RemoveFromRemainingGames(name);
@@ -241,7 +252,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;
@@ -251,7 +262,7 @@ internal sealed partial class SelectForm : CustomForm
}
else
{
CmdAppData dlcCmdAppData = await SteamCMD.GetAppInfo(dlcAppId);
CmdAppData dlcCmdAppData = await WithTimeout(SteamCMD.GetAppInfo(dlcAppId), 16000);
if (dlcCmdAppData is not null)
{
dlcName = dlcCmdAppData.Common?.Name;
@@ -271,7 +282,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;
@@ -342,11 +353,25 @@ internal sealed partial class SelectForm : CustomForm
Selection selection = Selection.GetOrCreate(Platform.Steam, appId, storeAppData?.Name ?? name,
gameDirectory, dllDirectories,
await gameDirectory.GetExecutableDirectories(true));
selection.SteamApiDllMissing = steamApiDllMissing;
if (steamApiDllMissing)
{
selection.UseProxy = true;
bool has64 = selection.ExecutableDirectories.Any(d => d.binaryType == BinaryType.BIT64);
bool has32 = selection.ExecutableDirectories.Any(d => d.binaryType == BinaryType.BIT32);
string dllName = (has64, has32) switch
{
(true, true) => "steam_api.dll / steam_api64.dll",
(true, false) => "steam_api64.dll",
_ => "steam_api.dll"
};
selection.TreeNode.ToolTipText = dllName + " was not detected in the game directory. Only proxy installation is available.";
}
selection.Product = "https://store.steampowered.com/app/" + appId;
selection.Icon = IconGrabber.SteamAppImagesPath + @$"\{appId}\{cmdAppData?.Common?.Icon}.jpg";
selection.SubIcon = storeAppData?.HeaderImage ?? IconGrabber.SteamAppImagesPath
+ @$"\{appId}\{cmdAppData?.Common?.ClientIcon}.ico";
selection.Publisher = storeAppData?.Publishers[0] ?? cmdAppData?.Extended?.Publisher;
selection.Publisher = storeAppData?.Publishers?.FirstOrDefault() ?? cmdAppData?.Extended?.Publisher;
selection.Website = storeAppData?.Website;
if (Program.Canceled)
return;
@@ -550,25 +575,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 +716,7 @@ internal sealed partial class SelectForm : CustomForm
}
OnLoadSelections(null, null);
await LoadSavedInstalledGames();
HideProgressBar();
selectionTreeView.Enabled = !Selection.All.IsEmpty;
allCheckBox.Enabled = selectionTreeView.Enabled;
@@ -703,6 +733,20 @@ internal sealed partial class SelectForm : CustomForm
blockProtectedHelpButton.Enabled = true;
useSmokeAPICheckBox.Enabled = true;
useSmokeAPIHelpButton.Enabled = true;
}
catch (Exception ex)
{
ProgramData.LogError("SelectForm OnLoad failed", ex);
// 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 +835,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 +1016,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 +1184,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 +1223,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 +1242,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)
{
@@ -1139,15 +1279,38 @@ internal sealed partial class SelectForm : CustomForm
selection.Proxy = currentProxy == Selection.DefaultProxy ? currentProxy : proxy;
}
}
else
else if (!selection.SteamApiDllMissing)
{
selection.UseProxy = false;
selection.Proxy = null;
}
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 +1318,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 +1334,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 +1352,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,19 +1393,41 @@ 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)
{
using DialogForm form = new(this);
_ = form.Show(SystemIcons.Information,
"InTest restore SmokeAPI in app. May be unstable."
+ "\n\nIf some games don't launch with it - try disable and reinstall unlock",
"[Experimental] WARNING: This may still be unstable.\n" +
"This setting restores the use of SmokeAPI.\n" +
"If some games don't launch with SmokeAPI enabled, try disabling this setting then Generate and Install again.",
customFormText: "Use SmokeAPI");
}
private void OnSortCheckBoxChanged(object sender, EventArgs e)
=> selectionTreeView.TreeViewNodeSorter =
sortCheckBox.Checked ? PlatformIdComparer.NodeText : PlatformIdComparer.NodeName;
private void programsGroupBox_Enter(object sender, EventArgs e)
{
}
private void OnDarkModeCheckBoxChanged(object sender, EventArgs e)
{
Program.DarkModeEnabled = darkModeCheckBox.Checked;
ThemeManager.ApplyToAllOpenForms();
}
protected override void OnShown(EventArgs e)
{
base.OnShown(e);
ThemeManager.Apply(this);
if (darkModeCheckBox is not null)
darkModeCheckBox.Checked = Program.DarkModeEnabled;
}
}
+215
View File
@@ -0,0 +1,215 @@
using System.ComponentModel;
using System.Windows.Forms;
namespace CreamInstaller.Forms;
partial class TestGameForm
{
private IContainer components = null;
protected override void Dispose(bool disposing)
{
if (disposing && components is not null)
components.Dispose();
base.Dispose(disposing);
}
// All coordinates are based on ClientSize = 560 x 330
// Left margin = 12, right edge of usable area = 548 (560 - 12)
// Usable width = 536
private void InitializeComponent()
{
platformGroupBox = new GroupBox();
steamRadioButton = new RadioButton();
epicRadioButton = new RadioButton();
appIdLabel = new Label();
appIdTextBox = new TextBox();
gameNameLabel = new Label();
gameNameTextBox = new TextBox();
epicSearchButton = new Button();
epicResultsListBox = new ListBox();
dlcGroupBox = new GroupBox();
dlcListBox = new ListBox();
dlcIdLabel = new Label();
dlcIdTextBox = new TextBox();
dlcNameLabel = new Label();
dlcNameTextBox = new TextBox();
addDlcButton = new Button();
removeDlcButton = new Button();
generateButton = new Button();
clearButton = new Button();
closeButton = new Button();
statusLabel = new Label();
platformGroupBox.SuspendLayout();
dlcGroupBox.SuspendLayout();
SuspendLayout();
// ── Platform group box ── y=8, h=44
platformGroupBox.Location = new System.Drawing.Point(12, 8);
platformGroupBox.Size = new System.Drawing.Size(536, 44);
platformGroupBox.TabStop = false;
platformGroupBox.Text = "Platform";
platformGroupBox.Controls.Add(steamRadioButton);
platformGroupBox.Controls.Add(epicRadioButton);
steamRadioButton.AutoSize = true;
steamRadioButton.Checked = true;
steamRadioButton.Location = new System.Drawing.Point(10, 17);
steamRadioButton.TabStop = true;
steamRadioButton.Text = "Steam";
steamRadioButton.CheckedChanged += OnPlatformChanged;
epicRadioButton.AutoSize = true;
epicRadioButton.Location = new System.Drawing.Point(80, 17);
epicRadioButton.Text = "Epic";
epicRadioButton.CheckedChanged += OnPlatformChanged;
// ── App ID row ── y=62
appIdLabel.AutoSize = true;
appIdLabel.Location = new System.Drawing.Point(12, 66);
appIdLabel.Text = "App ID:";
appIdTextBox.Location = new System.Drawing.Point(105, 63);
appIdTextBox.Size = new System.Drawing.Size(443, 23);
appIdTextBox.PlaceholderText = "e.g. 480";
// ── Game Name row ── y=96
gameNameLabel.AutoSize = true;
gameNameLabel.Location = new System.Drawing.Point(12, 100);
gameNameLabel.Text = "Game Name:";
// Steam: full width; Epic: leaves room for Search button (75px + 4px gap)
gameNameTextBox.Location = new System.Drawing.Point(105, 97);
gameNameTextBox.Size = new System.Drawing.Size(443, 23);
epicSearchButton.Location = new System.Drawing.Point(468, 97);
epicSearchButton.Size = new System.Drawing.Size(80, 23);
epicSearchButton.Text = "Search";
epicSearchButton.Visible = false;
epicSearchButton.Click += OnEpicSearch;
// ── Epic results list ── y=130, same slot as DLC group
epicResultsListBox.Location = new System.Drawing.Point(12, 130);
epicResultsListBox.Size = new System.Drawing.Size(536, 80);
epicResultsListBox.Visible = false;
epicResultsListBox.SelectedIndexChanged += OnEpicResultSelected;
// ── DLC group box ── y=130, h=130
dlcGroupBox.Location = new System.Drawing.Point(12, 130);
dlcGroupBox.Size = new System.Drawing.Size(536, 130);
dlcGroupBox.TabStop = false;
dlcGroupBox.Text = "DLC Entries (Steam only)";
dlcGroupBox.Controls.Add(dlcListBox);
dlcGroupBox.Controls.Add(dlcIdLabel);
dlcGroupBox.Controls.Add(dlcIdTextBox);
dlcGroupBox.Controls.Add(dlcNameLabel);
dlcGroupBox.Controls.Add(dlcNameTextBox);
dlcGroupBox.Controls.Add(addDlcButton);
dlcGroupBox.Controls.Add(removeDlcButton);
dlcListBox.Location = new System.Drawing.Point(6, 20);
dlcListBox.Size = new System.Drawing.Size(524, 60);
// DLC row inside group box — left-to-right:
// "DLC ID:" label + 70px box + "DLC Name:" label + 160px box + "Add"(60) + "Remove"(70)
// Total: ~48 + 70 + ~72 + 160 + 60 + 70 = 480 (fits in 524)
dlcIdLabel.AutoSize = true;
dlcIdLabel.Location = new System.Drawing.Point(6, 92);
dlcIdLabel.Text = "DLC ID:";
dlcIdTextBox.Location = new System.Drawing.Point(62, 89);
dlcIdTextBox.Size = new System.Drawing.Size(70, 23);
dlcIdTextBox.PlaceholderText = "e.g. 12345";
dlcNameLabel.AutoSize = true;
dlcNameLabel.Location = new System.Drawing.Point(140, 92);
dlcNameLabel.Text = "DLC Name:";
dlcNameTextBox.Location = new System.Drawing.Point(216, 89);
dlcNameTextBox.Size = new System.Drawing.Size(184, 23);
dlcNameTextBox.PlaceholderText = "e.g. Test DLC";
addDlcButton.Location = new System.Drawing.Point(406, 89);
addDlcButton.Size = new System.Drawing.Size(52, 23);
addDlcButton.Text = "Add";
addDlcButton.Click += OnAddDlc;
removeDlcButton.Location = new System.Drawing.Point(462, 89);
removeDlcButton.Size = new System.Drawing.Size(62, 23);
removeDlcButton.Text = "Remove";
removeDlcButton.Click += OnRemoveDlc;
// ── Action buttons ── y=270
generateButton.Location = new System.Drawing.Point(12, 270);
generateButton.Size = new System.Drawing.Size(150, 26);
generateButton.Text = "Generate Test Game";
generateButton.Click += OnGenerate;
clearButton.Location = new System.Drawing.Point(168, 270);
clearButton.Size = new System.Drawing.Size(110, 26);
clearButton.Text = "Clear All Tests";
clearButton.Click += OnClearAll;
closeButton.Location = new System.Drawing.Point(284, 270);
closeButton.Size = new System.Drawing.Size(70, 26);
closeButton.Text = "Close";
closeButton.Click += OnClose;
// ── Status label ── y=302
statusLabel.Location = new System.Drawing.Point(12, 302);
statusLabel.Size = new System.Drawing.Size(536, 20);
statusLabel.Font = new System.Drawing.Font("Segoe UI", 8.25F);
// ── Form ──
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
AutoScaleMode = AutoScaleMode.Font;
ClientSize = new System.Drawing.Size(560, 328);
FormBorderStyle = FormBorderStyle.FixedDialog;
MaximizeBox = false;
MinimizeBox = false;
StartPosition = FormStartPosition.CenterParent;
Text = "Test Game Generator";
Controls.Add(platformGroupBox);
Controls.Add(appIdLabel);
Controls.Add(appIdTextBox);
Controls.Add(gameNameLabel);
Controls.Add(gameNameTextBox);
Controls.Add(epicSearchButton);
Controls.Add(epicResultsListBox);
Controls.Add(dlcGroupBox);
Controls.Add(generateButton);
Controls.Add(clearButton);
Controls.Add(closeButton);
Controls.Add(statusLabel);
platformGroupBox.ResumeLayout(false);
platformGroupBox.PerformLayout();
dlcGroupBox.ResumeLayout(false);
dlcGroupBox.PerformLayout();
ResumeLayout(false);
PerformLayout();
}
private GroupBox platformGroupBox;
private RadioButton steamRadioButton;
private RadioButton epicRadioButton;
private Label appIdLabel;
private TextBox appIdTextBox;
private Label gameNameLabel;
private TextBox gameNameTextBox;
private Button epicSearchButton;
private ListBox epicResultsListBox;
private GroupBox dlcGroupBox;
private ListBox dlcListBox;
private Label dlcIdLabel;
private TextBox dlcIdTextBox;
private Label dlcNameLabel;
private TextBox dlcNameTextBox;
private Button addDlcButton;
private Button removeDlcButton;
private Button generateButton;
private Button clearButton;
private Button closeButton;
private Label statusLabel;
}
+361
View File
@@ -0,0 +1,361 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
using CreamInstaller.Components;
using CreamInstaller.Platforms.Epic;
using CreamInstaller.Platforms.Steam;
using CreamInstaller.Utility;
namespace CreamInstaller.Forms;
internal sealed partial class TestGameForm : CustomForm
{
private static readonly string TestGamesRoot =
Path.Combine(ProgramData.DirectoryPath, "TestGames");
private static readonly List<string> CreatedDirectories = [];
// Steam DLC entries per-form: (dlcId, dlcName)
private readonly List<(string id, string name)> dlcEntries = [];
// Cached Epic search results from the last search: (namespace, name)
private readonly List<(string ns, string name)> epicSearchResults = [];
private bool IsEpicMode => epicRadioButton.Checked;
internal TestGameForm(IWin32Window owner) : base(owner)
{
InitializeComponent();
appIdTextBox.Leave += OnAppIdLeave;
RefreshDlcList();
UpdatePlatformMode();
}
private void UpdatePlatformMode()
{
bool epic = IsEpicMode;
// App ID row: Steam only
appIdLabel.Visible = !epic;
appIdTextBox.Visible = !epic;
// Search button: Epic only — shrink the game name box to make room
epicSearchButton.Visible = epic;
gameNameTextBox.Size = new System.Drawing.Size(epic ? 354 : 443, 23);
// Placeholder text — call RefreshCueBanner to flush the Win32 cue so only one text shows
gameNameTextBox.PlaceholderText = epic ? "Enter game name and click Search" : "e.g. Spacewar";
NativeMethods.RefreshCueBanner(gameNameTextBox);
// DLC group and Epic results share the same vertical slot
dlcGroupBox.Visible = !epic;
epicResultsListBox.Visible = false; // hidden until search runs
if (!epic)
epicSearchResults.Clear();
SetStatus(epic
? "Enter a game name and click Search to find it on the Epic store."
: "Enter the App ID, then tab out to auto-detect the game name.");
}
private void OnPlatformChanged(object sender, EventArgs e) => UpdatePlatformMode();
// ── Steam: auto-detect name from AppID ──────────────────────────────────
private async void OnAppIdLeave(object sender, EventArgs e)
{
if (IsEpicMode)
return;
string appId = appIdTextBox.Text.Trim();
if (string.IsNullOrWhiteSpace(appId) || !int.TryParse(appId, out _))
return;
if (!string.IsNullOrWhiteSpace(gameNameTextBox.Text))
return;
SetStatus("Looking up game name . . .");
generateButton.Enabled = false;
string name = await Task.Run(async () =>
{
// Use an isolated client with neutral UA so Steam's store API doesn't reject the request.
using System.Net.Http.HttpClient client = HttpClientManager.CreateIsolatedClient();
string url = $"https://store.steampowered.com/api/appdetails?appids={appId}&filters=basic";
try
{
string json = await client.GetStringAsync(url);
Newtonsoft.Json.Linq.JObject root = Newtonsoft.Json.Linq.JObject.Parse(json);
string title = root[appId]?["data"]?["name"]?.ToString();
if (!string.IsNullOrWhiteSpace(title))
return title;
}
catch (Exception ex) { ProgramData.LogWarning($"[TestGame] Store name lookup failed for AppID {appId}: {ex.Message}"); /* 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 (Exception ex) { ProgramData.LogWarning($"[TestGame] Cleanup deletion failed for {dir}: {ex.Message}"); }
CreatedDirectories.Clear();
dlcEntries.Clear();
RefreshDlcList();
epicSearchResults.Clear();
epicResultsListBox.Items.Clear();
epicResultsListBox.Visible = false;
SetStatus("All test games cleared. Press Rescan in the main window.");
}
private void OnClose(object sender, EventArgs e) => Close();
// ── Helpers ──────────────────────────────────────────────────────────────
private void SetStatus(string message)
{
statusLabel.Text = message;
statusLabel.ForeColor = message.StartsWith("✓", StringComparison.Ordinal)
? System.Drawing.Color.Green
: System.Drawing.Color.FromArgb(212, 212, 212);
}
private static string SanitizeName(string name)
{
char[] invalid = Path.GetInvalidFileNameChars();
return new string(name.Select(c => invalid.Contains(c) ? '_' : c).ToArray());
}
private static void WriteSteamApiStub(string path)
{
byte[] mzStub =
[
0x4D, 0x5A,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00
];
File.WriteAllBytes(path, mzStub);
}
}
+120
View File
@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>
+192 -165
View File
@@ -40,81 +40,98 @@ internal sealed partial class UpdateForm : CustomForm
#if DEBUG
DebugForm.Current.Attach(form);
#endif
ThemeManager.Apply(form); // apply current theme when transitioning
}
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)
{
ProgramData.LogError("UpdateForm OnLoad failed", ex);
#if DEBUG
ex.HandleFatalException();
#else
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();
}
}
}
@@ -122,122 +139,132 @@ 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}\"");
#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)
ProgramData.LogError("UpdateForm OnUpdate failed", ex);
// 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"))
+56 -14
View File
@@ -8,9 +8,7 @@ using CreamInstaller.Platforms.Epic.GraphQL;
using CreamInstaller.Utility;
using Newtonsoft.Json;
#if DEBUG
using CreamInstaller.Forms;
#endif
namespace CreamInstaller.Platforms.Epic;
@@ -33,12 +31,10 @@ internal static class EpicStore
if (!cachedExists || ProgramData.CheckCooldown(categoryNamespace, Cooldown))
{
response = await QueryGraphQL(categoryNamespace);
#if DEBUG
if (response is null)
{
DebugForm.Current.Log("ES: QueryGraphQL returned null");
ProgramData.LogWarning("Epic QueryGraphQL returned null for " + categoryNamespace);
}
#endif
try
{
cacheFile.WriteFile(JsonConvert.SerializeObject(response, Formatting.Indented));
@@ -58,13 +54,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 +77,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 +114,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 +128,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
@@ -143,9 +187,7 @@ internal static class EpicStore
HttpClient client = HttpClientManager.HttpClient;
if (client is null)
{
#if DEBUG
DebugForm.Current.Log("ES: Client returned null");
#endif
ProgramData.LogWarning("Epic GraphQL client returned null");
return null;
}
HttpResponseMessage httpResponse =
@@ -2,7 +2,6 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CreamInstaller.Forms;
using CreamInstaller.Utility;
using Newtonsoft.Json;
@@ -38,61 +37,38 @@ internal static partial class SteamCMD
{
cacheFile.WriteFile(JsonConvert.SerializeObject(data, Formatting.Indented));
}
catch
#if DEBUG
(Exception e)
catch (Exception e)
{
DebugForm.Current.Log("SteamCMD web API query failed on attempt #" + attempts +
" for " + appId + (isDlc ? " (DLC)" : "")
+ ": Unsuccessful serialization (" + e.Message + ")");
ProgramData.LogSteamCmd("SteamCMD web API query failed on attempt #" + attempts +
" for " + appId + (isDlc ? " (DLC)" : "")
+ ": Unsuccessful serialization (" + e.Message + ")");
}
#else
{
// ignored
}
#endif
return data;
}
#if DEBUG
else
DebugForm.Current.Log(
ProgramData.LogSteamCmd(
"SteamCMD web API query failed on attempt #" + attempts + " for " + appId +
(isDlc ? " (DLC)" : "")
+ ": No data",
LogTextBox.Warning);
#endif
+ ": No data");
}
#if DEBUG
else
DebugForm.Current.Log(
ProgramData.LogSteamCmd(
"SteamCMD web API query failed on attempt #" + attempts + " for " + appId +
(isDlc ? " (DLC)" : "")
+ ": Status not success (" + appDetails?.Status + ")",
LogTextBox.Warning);
#endif
+ ": Status not success (" + appDetails?.Status + ")");
}
catch
#if DEBUG
(Exception e)
catch (Exception e)
{
DebugForm.Current.Log("SteamCMD web API query failed on attempt #" + attempts + " for " +
appId + (isDlc ? " (DLC)" : "")
+ ": Unsuccessful deserialization (" + e.Message + ")");
ProgramData.LogSteamCmd("SteamCMD web API query failed on attempt #" + attempts + " for " +
appId + (isDlc ? " (DLC)" : "")
+ ": Unsuccessful deserialization (" + e.Message + ")");
}
#else
{
// ignored
}
#endif
}
#if DEBUG
else
DebugForm.Current.Log(
ProgramData.LogSteamCmd(
"SteamCMD web API query failed on attempt #" + attempts + " for " + appId +
(isDlc ? " (DLC)" : "") +
": Response null",
LogTextBox.Warning);
#endif
": Response null");
}
if (cachedExists)
@@ -109,9 +85,7 @@ internal static partial class SteamCMD
break;
if (attempts > 10)
{
#if DEBUG
DebugForm.Current.Log("Failed to query SteamCMD web API after 10 tries: " + appId);
#endif
ProgramData.LogSteamCmd("Failed to query SteamCMD web API after 10 tries: " + appId);
break;
}
+107 -116
View File
@@ -12,9 +12,6 @@ using CreamInstaller.Resources;
using CreamInstaller.Utility;
using Gameloop.Vdf.JsonConverter;
using Gameloop.Vdf.Linq;
#if DEBUG
using CreamInstaller.Forms;
#endif
namespace CreamInstaller.Platforms.Steam;
@@ -46,82 +43,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 +128,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())
@@ -196,10 +207,7 @@ internal static partial class SteamCMD
attempts++;
if (attempts > 10)
{
#if DEBUG
DebugForm.Current.Log("Failed to query SteamCMD after 10 tries: " + appId + " (" + branch + ")",
LogTextBox.Warning);
#endif
ProgramData.LogSteamCmd("Failed to query SteamCMD after 10 tries: " + appId + " (" + branch + ")");
break;
}
@@ -218,12 +226,9 @@ internal static partial class SteamCMD
}
else
{
#if DEBUG
DebugForm.Current.Log(
ProgramData.LogSteamCmd(
"SteamCMD query failed on attempt #" + attempts + " for " + appId + " (" + branch +
"): Bad output",
LogTextBox.Warning);
#endif
"): Bad output");
continue;
}
}
@@ -231,12 +236,9 @@ internal static partial class SteamCMD
if (!ValveDataFile.TryDeserialize(output, out VProperty appInfo) || appInfo.Value is VValue)
{
appUpdateFile.DeleteFile();
#if DEBUG
DebugForm.Current.Log(
ProgramData.LogSteamCmd(
"SteamCMD query failed on attempt #" + attempts + " for " + appId + " (" + branch +
"): Deserialization failed",
LogTextBox.Warning);
#endif
"): Deserialization failed");
continue;
}
@@ -246,29 +248,20 @@ internal static partial class SteamCMD
if (appInfo.ToJson().Value.ToObject<CmdAppData>() is not { } cmdAppData)
{
appUpdateFile.DeleteFile();
#if DEBUG
DebugForm.Current.Log(
ProgramData.LogSteamCmd(
"SteamCMD query failed on attempt #" + attempts + " for " + appId + " (" + branch +
"): VDF-JSON conversion failed",
LogTextBox.Warning);
#endif
"): VDF-JSON conversion failed");
continue;
}
appData = cmdAppData;
}
catch
#if DEBUG
(Exception e)
#endif
catch (Exception e)
{
appUpdateFile.DeleteFile();
#if DEBUG
DebugForm.Current.Log(
ProgramData.LogSteamCmd(
"SteamCMD query failed on attempt #" + attempts + " for " + appId + " (" + branch +
"): VDF-JSON conversion failed (" + e.Message + ")",
LogTextBox.Warning);
#endif
"): VDF-JSON conversion failed (" + e.Message + ")");
continue;
}
@@ -286,11 +279,9 @@ internal static partial class SteamCMD
foreach (string dlcAppUpdateFile in dlcAppIds.Select(id => $@"{AppInfoPath}\{id}.vdf"))
dlcAppUpdateFile.DeleteFile();
appUpdateFile.DeleteFile();
#if DEBUG
DebugForm.Current.Log(
ProgramData.LogSteamCmd(
"SteamCMD query skipped on attempt #" + attempts + " for " + appId + " (" + branch +
"): Outdated cache", LogTextBox.Warning);
#endif
"): Outdated cache");
}
return null;
+67 -8
View File
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using CreamInstaller.Utility;
@@ -9,6 +10,9 @@ namespace CreamInstaller.Platforms.Steam;
internal static class SteamLibrary
{
internal static readonly List<(string appId, string name, string branch, int buildId, string gameDirectory)>
TestGames = [];
private static string installPath;
internal static string InstallPath
@@ -28,16 +32,24 @@ internal static class SteamLibrary
{
List<(string appId, string name, string branch, int buildId, string gameDirectory)> games = new();
HashSet<string> gameLibraryDirectories = await GetLibraryDirectories();
ProgramData.Log($"[Steam] Found {gameLibraryDirectories.Count} library folder(s).");
foreach (string libraryDirectory in gameLibraryDirectories)
{
if (Program.Canceled)
return games;
ProgramData.Log($"[Steam] Scanning library: {libraryDirectory}");
foreach ((string appId, string name, string branch, int buildId, string gameDirectory) game in (await
GetGamesFromLibraryDirectory(
libraryDirectory)).Where(game => games.All(_game => _game.appId != game.appId)))
games.Add(game);
}
foreach ((string appId, string name, string branch, int buildId, string gameDirectory) testGame in
TestGames.Where(t => games.All(g => g.appId != t.appId)))
games.Add(testGame);
if (TestGames.Count > 0)
ProgramData.Log($"[Steam] Injected {TestGames.Count} test game(s).");
ProgramData.Log($"[Steam] Total games detected: {games.Count}");
return games;
});
@@ -47,13 +59,21 @@ internal static class SteamLibrary
{
List<(string appId, string name, string branch, int buildId, string gameDirectory)> games = new();
if (Program.Canceled || !libraryDirectory.DirectoryExists())
{
ProgramData.Log($"[Steam] Skipping library (not found or canceled): {libraryDirectory}");
return games;
}
foreach (string file in libraryDirectory.EnumerateDirectory("*.acf"))
{
if (Program.Canceled)
return games;
if (!ValveDataFile.TryDeserialize(file.ReadFile(), out VProperty result))
{
ProgramData.Log($"[Steam] Failed to deserialize ACF: {file}");
continue;
}
string appId = result.Value.GetChild("appid")?.ToString();
string installdir = result.Value.GetChild("installdir")?.ToString();
string name = result.Value.GetChild("name")?.ToString();
@@ -61,11 +81,23 @@ internal static class SteamLibrary
if (string.IsNullOrWhiteSpace(appId) || string.IsNullOrWhiteSpace(installdir) ||
string.IsNullOrWhiteSpace(name)
|| string.IsNullOrWhiteSpace(buildId))
{
ProgramData.Log($"[Steam] Skipping ACF with missing fields: {file}");
continue;
string gameDirectory = (libraryDirectory + @"\common\" + installdir).ResolvePath();
if (gameDirectory is null || !int.TryParse(appId, out int _) ||
!int.TryParse(buildId, out int buildIdInt) || games.Any(g => g.appId == appId))
}
string rawGameDirectory = libraryDirectory + @"\common\" + installdir;
string gameDirectory = rawGameDirectory.ResolvePath();
if (gameDirectory is null)
{
ProgramData.Log($"[Steam] Game directory not found (drive may be slow or disconnected): {rawGameDirectory} | App: {name} ({appId})");
continue;
}
if (!int.TryParse(appId, out int _) || !int.TryParse(buildId, out int buildIdInt) ||
games.Any(g => g.appId == appId))
continue;
VToken userConfig = result.Value.GetChild("UserConfig");
string branch = userConfig?.GetChild("BetaKey")?.ToString();
branch ??= userConfig?.GetChild("betakey")?.ToString();
@@ -78,6 +110,8 @@ internal static class SteamLibrary
if (string.IsNullOrWhiteSpace(branch))
branch = "public";
ProgramData.Log($"[Steam] Detected game: {name} ({appId}) | Branch: {branch} | Dir: {gameDirectory}");
games.Add((appId, name, branch, buildIdInt, gameDirectory));
}
@@ -92,25 +126,50 @@ internal static class SteamLibrary
return libraryDirectories;
string steamInstallPath = InstallPath;
if (steamInstallPath == null || !steamInstallPath.DirectoryExists())
{
ProgramData.Log($"[Steam] Steam install path not found or inaccessible: {steamInstallPath ?? "(null)"}");
return libraryDirectories;
}
string libraryFolder = steamInstallPath + @"\steamapps";
if (!libraryFolder.DirectoryExists())
{
ProgramData.Log($"[Steam] Default steamapps folder not found: {libraryFolder}");
return libraryDirectories;
}
_ = libraryDirectories.Add(libraryFolder);
ProgramData.Log($"[Steam] Default library folder: {libraryFolder}");
string libraryFolders = libraryFolder + @"\libraryfolders.vdf";
if (!libraryFolders.FileExists() ||
!ValveDataFile.TryDeserialize(libraryFolders.ReadFile(), out VProperty result))
{
ProgramData.Log($"[Steam] libraryfolders.vdf not found or failed to parse: {libraryFolders}");
return libraryDirectories;
}
foreach (VToken vToken in result.Value.Where(p =>
p is VProperty property && int.TryParse(property.Key, out int _)))
{
VProperty property = (VProperty)vToken;
string path = property.Value.GetChild("path")?.ToString();
if (string.IsNullOrWhiteSpace(path))
string rawPath = property.Value.GetChild("path")?.ToString();
if (string.IsNullOrWhiteSpace(rawPath))
continue;
path += @"\steamapps";
if (path.DirectoryExists())
_ = libraryDirectories.Add(path);
// Normalize the path from VDF (may use forward slashes or wrong casing)
string normalizedPath = Path.GetFullPath(rawPath);
string steamappsPath = normalizedPath + @"\steamapps";
string resolvedPath = steamappsPath.ResolvePath();
if (resolvedPath is null)
{
ProgramData.Log($"[Steam] External library not accessible (drive may be disconnected or letter changed): {steamappsPath}");
continue;
}
if (libraryDirectories.Add(resolvedPath))
ProgramData.Log($"[Steam] Additional library folder found: {resolvedPath}");
}
return libraryDirectories;
+47 -60
View File
@@ -3,13 +3,10 @@ using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System;
using CreamInstaller.Utility;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
#if DEBUG
using System;
using CreamInstaller.Forms;
#endif
namespace CreamInstaller.Platforms.Steam;
@@ -18,6 +15,23 @@ internal static class SteamStore
private const int CooldownGame = 600;
private const int CooldownDlc = 1200;
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}";
}
internal static async Task<HashSet<string>> ParseDlcAppIds(StoreAppData storeAppData)
=> await Task.Run(() =>
{
@@ -31,8 +45,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,15 +70,13 @@ 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) + ")",
LogTextBox.Warning);
#endif
ProgramData.LogSteamCmd(
FormatErrorLog(attempts, appId, gameName, isDlc, "Query unsuccessful", parentGameName, parentGameAppId));
if (data is null)
return null;
}
@@ -74,63 +87,38 @@ internal static class SteamStore
{
cacheFile.WriteFile(JsonConvert.SerializeObject(data, Formatting.Indented));
}
catch
#if DEBUG
(Exception e)
catch (Exception e)
{
DebugForm.Current.Log("Steam store query failed on attempt #" + attempts +
" for " + appId + (isDlc ? " (DLC)" : "")
+ ": Unsuccessful serialization (" + e.Message + ")");
ProgramData.LogSteamCmd(
FormatErrorLog(attempts, appId, gameName, isDlc, $"Unsuccessful serialization ({e.Message})", parentGameName, parentGameAppId));
}
#else
{
// ignored
}
#endif
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) + ")");
#endif
ProgramData.LogSteamCmd(
FormatErrorLog(attempts, appId, gameName, isDlc, "Response data null", parentGameName, parentGameAppId));
}
#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) + ")");
#endif
{
ProgramData.LogSteamCmd(
FormatErrorLog(attempts, appId, gameName, isDlc, "Response details null", parentGameName, parentGameAppId));
}
}
catch
#if DEBUG
(Exception e)
catch (Exception e)
{
DebugForm.Current.Log("Steam store query failed on attempt #" + attempts + " for " +
appId + (isDlc ? " (DLC)" : "")
+ ": Unsuccessful deserialization (" + e.Message + ")");
ProgramData.LogSteamCmd(
FormatErrorLog(attempts, appId, gameName, isDlc, $"Unsuccessful deserialization ({e.Message})", parentGameName, parentGameAppId));
}
#else
{
// ignored
}
#endif
#if DEBUG
else
DebugForm.Current.Log("Steam store query failed on attempt #" + attempts + " for " + appId +
(isDlc ? " (DLC)" : "")
+ ": Response deserialization null");
#endif
{
ProgramData.LogSteamCmd(
FormatErrorLog(attempts, appId, gameName, isDlc, "Response deserialization null", parentGameName, parentGameAppId));
}
}
#if DEBUG
else
DebugForm.Current.Log(
"Steam store query failed on attempt #" + attempts + " for " + appId + (isDlc ? " (DLC)" : "") +
": Response null",
LogTextBox.Warning);
#endif
{
ProgramData.LogSteamCmd(
FormatErrorLog(attempts, appId, gameName, isDlc, "Null or empty response", parentGameName, parentGameAppId));
}
}
if (cachedExists)
@@ -147,9 +135,8 @@ internal static class SteamStore
break;
if (attempts > 10)
{
#if DEBUG
DebugForm.Current.Log("Failed to query Steam store after 10 tries: " + appId);
#endif
ProgramData.LogSteamCmd(
FormatErrorLog(attempts, appId, gameName, isDlc, "Maximum retry attempts exceeded (10)", parentGameName, parentGameAppId));
break;
}
+80 -19
View File
@@ -2,6 +2,7 @@ using System;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
using CreamInstaller.Forms;
using CreamInstaller.Platforms.Steam;
@@ -19,7 +20,7 @@ internal static class Program
? index
: Application.ProductVersion.Length)];
internal const string RepositoryOwner = "HvTcCore";
internal const string RepositoryOwner = "FroggMaster";
internal static readonly string RepositoryName = Name;
internal static readonly string RepositoryPackage = Name + ".zip";
internal static readonly string RepositoryExecutable = Name + ".exe";
@@ -35,7 +36,7 @@ internal static class Program
internal static readonly string CurrentProcessFilePath = CurrentProcess.MainModule?.FileName;
internal static readonly int CurrentProcessId = CurrentProcess.Id;
// this may forever be false, but who knows, maybe acidicoala makes it once again better than CreamAPI some day
// Setting is now toggleable. Huzzah!
internal static bool UseSmokeAPI = true;
internal static bool BlockProtectedGames = true;
@@ -43,6 +44,9 @@ internal static class Program
internal static readonly string[] ProtectedGameDirectories = [@"\EasyAntiCheat", @"\BattlEye"];
internal static readonly string[] ProtectedGameDirectoryExceptions = [];
// Dark mode enabled by default
internal static bool DarkModeEnabled = true;
internal static bool IsGameBlocked(string name, string directory = null)
=> BlockProtectedGames && (ProtectedGames.Contains(name) || directory is not null &&
!ProtectedGameDirectoryExceptions.Contains(name)
@@ -62,22 +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
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;
}
}
}
}
@@ -86,15 +98,64 @@ 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)
{
ProgramData.LogWarning($"Cleanup failed: {ex.Message}");
}
});
}
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)))
{
ProgramData.LogWarning("Cleanup timed out during application exit");
}
}
catch (Exception ex)
{
ProgramData.LogWarning($"Cleanup exception during exit: {ex.Message}");
}
finally
{
HttpClientManager.Dispose();
}
}
}
+5 -6
View File
@@ -37,20 +37,19 @@ internal static class CreamAPI
_ = dlc.Add(extraDlc);
config.DeleteFile();
installForm?.UpdateUser($"Deleted unnecessary configuration: {Path.GetFileName(config)}", LogTextBox.Action, false);
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;
installForm?.UpdateUser($"Deleted unnecessary configuration: {Path.GetFileName(config)}", LogTextBox.Action,
false);
}
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]");
@@ -60,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
+11 -3
View File
@@ -167,7 +167,7 @@ internal static class SmokeAPI
}
}
writer.WriteLine(" },");
writer.WriteLine(" }");
}
else
writer.WriteLine(" \"extra_dlcs\": {}");
@@ -367,7 +367,11 @@ internal static class SmokeAPI
"A3873569DECAD08962C46E88352E6DB1", // SmokeAPI v2.0.4
"4A1A823E5CF4FB861DD6BA94539D29C4", // SmokeAPI v2.0.5
"EC153C0CCE476AFFB2458575930F11E6", // SmokeAPI v3.1.5
"E833ACE855245D5939EE36FF25D8B4A4" // SmokeAPI v4.0.0
"E833ACE855245D5939EE36FF25D8B4A4", // SmokeAPI v4.0.0
"A2728FC65BFF3305F43F87CB6E3AE448", // SmokeAPI v4.1.0
"CA6B8DE96022A70C45E11FF6D0B55857", // SmokeAPI v4.1.1
"0438477117293DF1EAE1B4D87E8CE084", // SmokeAPI v4.1.2
"2B2413E3CCDA93C3821711D089129D34" // SmokeAPI v4.1.3
],
[ResourceIdentifier.Steamworks64] =
[
@@ -384,7 +388,11 @@ internal static class SmokeAPI
"C0DDB49C9BFD3E05CBC1C61D117E93F9", // SmokeAPI v2.0.4
"F7C3064D5E3C892B168F504C21AC4923", // SmokeAPI v2.0.5
"5A6712770EC7CE589252706245E62C72", // SmokeAPI v3.1.5
"22DD39B16D3C10FDB044FDCB1BAE63B8" // SmokeAPI v4.0.0
"22DD39B16D3C10FDB044FDCB1BAE63B8", // SmokeAPI v4.0.0
"997656BEB55D1D87918D0BF96BD5312F", // SmokeAPI v4.1.0
"CD628177EC5D6303043E35DB6A83AB30", // SmokeAPI v4.1.1
"3AC05641AA561C11BE706782B5D3C49D", // SmokeAPI v4.1.2
"B87E96F9A52D98A957B252CDAB61CBE8" // SmokeAPI v4.1.3
]
};
}
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -68,10 +68,10 @@ internal static class UplayR1
false);
}
writer.WriteLine(" ],");
writer.WriteLine(" ]");
}
else
writer.WriteLine(" \"blacklist\": [],");
writer.WriteLine(" \"blacklist\": []");
writer.WriteLine("}");
}
+2 -2
View File
@@ -72,10 +72,10 @@ internal static class UplayR2
false);
}
writer.WriteLine(" ],");
writer.WriteLine(" ]");
}
else
writer.WriteLine(" \"blacklist\": [],");
writer.WriteLine(" \"blacklist\": []");
writer.WriteLine("}");
}
+125 -4
View File
@@ -1,13 +1,16 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Windows.Forms;
using CreamInstaller.Forms;
using CreamInstaller.Platforms.Epic;
using CreamInstaller.Platforms.Steam;
using CreamInstaller.Platforms.Ubisoft;
using CreamInstaller.Resources;
using CreamInstaller.Utility;
using static CreamInstaller.Resources.Resources;
namespace CreamInstaller;
public enum Platform
@@ -34,12 +37,15 @@ 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 bool SteamApiDllMissing;
internal IEnumerable<string> GetAvailableProxies()
{
@@ -120,9 +126,12 @@ internal sealed class Selection : IEquatable<Selection>
return;
}
_ = DllDirectories.RemoveWhere(directory => !directory.DirectoryExists());
if (DllDirectories.Count < 1)
Remove();
if (!SteamApiDllMissing)
{
_ = DllDirectories.RemoveWhere(directory => !directory.DirectoryExists());
if (DllDirectories.Count < 1)
Remove();
}
}
internal static void ValidateAll(List<(Platform platform, string id, string name)> programsToScan)
@@ -134,6 +143,118 @@ 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;
if (Platform is Platform.Steam or Platform.Paradox)
{
directory.GetSmokeApiComponents(out _, out _, out _, out _, out _, out string smokeConfig, out _, out _, out _);
if (smokeConfig.FileExists())
return InstalledUnlocker.SmokeAPI;
directory.GetCreamApiComponents(out _, out _, out _, out _, out string creamConfig);
if (creamConfig.FileExists())
return InstalledUnlocker.CreamAPI;
if (directory.GetSmokeApiProxies().Any(proxy =>
proxy.FileExists() && (proxy.IsResourceFile(ResourceIdentifier.Steamworks32) ||
proxy.IsResourceFile(ResourceIdentifier.Steamworks64))))
return InstalledUnlocker.SmokeAPI;
if (directory.GetCreamApiProxies().Any(proxy =>
proxy.FileExists() && (proxy.IsResourceFile(ResourceIdentifier.Steamworks32) ||
proxy.IsResourceFile(ResourceIdentifier.Steamworks64))))
return InstalledUnlocker.CreamAPI;
}
}
return InstalledUnlocker.None;
}
private void ReadCreamApiConfig(string configPath)
{
try
{
if (!configPath.FileExists())
return;
string[] lines = File.ReadAllLines(configPath);
foreach (string line in lines)
{
string trimmed = line.Trim();
if (trimmed.StartsWith("extraprotection", StringComparison.OrdinalIgnoreCase))
{
string[] parts = trimmed.Split('=');
if (parts.Length == 2)
{
string value = parts[1].Trim();
UseExtraProtection = value.Equals("true", StringComparison.OrdinalIgnoreCase);
}
break;
}
}
}
catch
{
// If we can't read the config, leave UseExtraProtection at its default value
}
}
public override bool Equals(object obj) => ReferenceEquals(this, obj) || obj is Selection other && Equals(other);
public override int GetHashCode() => HashCode.Combine(Id, (int)Platform);
+8 -5
View File
@@ -22,17 +22,20 @@ internal sealed class SelectionDLC : IEquatable<SelectionDLC>
internal readonly string Name;
internal readonly TreeNode TreeNode;
internal readonly DLCType Type;
internal readonly string GameId;
internal string Icon;
internal string Product;
internal string Publisher;
private Selection selection;
private SelectionDLC(DLCType type, string id, string name)
private SelectionDLC(DLCType type, string gameId, string id, string name)
{
Type = type;
GameId = gameId;
Id = id;
Name = name;
TreeNode = new() { Tag = Type, Name = Id, Text = Name };
_ = All.TryAdd(this, 0);
}
internal bool Enabled
@@ -65,15 +68,15 @@ internal sealed class SelectionDLC : IEquatable<SelectionDLC>
public bool Equals(SelectionDLC other)
=> other is not null && (ReferenceEquals(this, other) ||
Type == other.Type && Selection?.Id == other.Selection?.Id && Id == other.Id);
Type == other.Type && GameId == other.GameId && Id == other.Id);
internal static SelectionDLC GetOrCreate(DLCType type, string gameId, string id, string name)
=> FromId(type, gameId, id) ?? new SelectionDLC(type, id, name);
=> FromId(type, gameId, id) ?? new SelectionDLC(type, gameId, id, name);
internal static SelectionDLC FromId(DLCType type, string gameId, string dlcId)
=> All.Keys.FirstOrDefault(dlc => dlc.Type == type && dlc.Selection?.Id == gameId && dlc.Id == dlcId);
=> All.Keys.FirstOrDefault(dlc => dlc.Type == type && dlc.GameId == gameId && dlc.Id == dlcId);
public override bool Equals(object obj) => ReferenceEquals(this, obj) || obj is SelectionDLC other && Equals(other);
public override int GetHashCode() => HashCode.Combine((int)Type, Selection?.Id, Id);
public override int GetHashCode() => HashCode.Combine((int)Type, GameId, Id);
}
+11 -2
View File
@@ -54,7 +54,16 @@ internal static class Diagnostics
if (info.Parent is null)
return info.Name.ToUpperInvariant();
string parent = ResolvePath(info.Parent.FullName);
string name = info.Parent.GetFileSystemInfos(info.Name)[0].Name;
return parent is null ? name : Path.Combine(parent, name);
try
{
FileSystemInfo[] infos = info.Parent.GetFileSystemInfos(info.Name);
string name = infos.Length > 0 ? infos[0].Name : info.Name;
return parent is null ? name : Path.Combine(parent, name);
}
catch
{
// Fall back to the raw name if the filesystem call fails (e.g. on a slow external drive)
return parent is null ? info.Name : Path.Combine(parent, info.Name);
}
}
}
+94 -29
View File
@@ -5,31 +5,65 @@ using System.Globalization;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
#if DEBUG
using CreamInstaller.Forms;
#endif
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)
@@ -51,29 +85,28 @@ internal static class HttpClientManager
{
if (e.StatusCode != HttpStatusCode.TooManyRequests)
{
#if DEBUG
DebugForm.Current.Log("Get request failed to " + url + ": " + e, LogTextBox.Warning);
#endif
string statusInfo = e.StatusCode.HasValue ? $" (HTTP {(int)e.StatusCode.Value})" : "";
ProgramData.LogWarning($"Get request failed to {url}{statusInfo}: {e.Message}");
return null;
}
#if DEBUG
DebugForm.Current.Log("Too many requests to " + url, LogTextBox.Error);
#endif
// do something special?
ProgramData.LogWarning($"Too many requests to {url} (HTTP 429 - Rate Limited)");
return null;
}
catch (TaskCanceledException)
{
ProgramData.LogWarning("Get request timed out for " + url);
return null;
}
catch (OperationCanceledException)
{
ProgramData.LogWarning("Get request was cancelled for " + url);
return null;
}
#if DEBUG
catch (Exception e)
{
DebugForm.Current.Log("Get request failed to " + url + ": " + e, LogTextBox.Warning);
ProgramData.LogWarning("Get request failed to " + url + ": " + e.Message);
return null;
}
#else
catch
{
return null;
}
#endif
}
internal static async Task<Image> GetImageFromUrl(string url)
@@ -88,5 +121,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;
}
}
}
+5
View File
@@ -21,4 +21,9 @@ internal static partial class NativeImports
[LibraryImport("user32.dll", SetLastError = true), DefaultDllImportSearchPaths(DllImportSearchPath.System32)]
internal static partial void SetWindowPos(nint hWnd, nint hWndInsertAfter, int x, int y, int cx, int cy,
uint uFlags);
// Windows theming (scrollbars / dark mode for some controls)
[LibraryImport("uxtheme.dll", SetLastError = true)]
internal static partial int SetWindowTheme(nint hWnd, [MarshalAs(UnmanagedType.LPWStr)] string pszSubAppName,
[MarshalAs(UnmanagedType.LPWStr)] string pszSubIdList);
}
+203
View File
@@ -1,13 +1,47 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
using CreamInstaller;
using Newtonsoft.Json;
namespace CreamInstaller.Utility;
internal enum InstalledUnlocker
{
None = 0,
CreamAPI,
SmokeAPI,
ScreamAPI,
UplayR1,
UplayR2,
Koaloader
}
internal sealed class InstalledDlcRecord
{
public string DlcType { get; set; }
public string Id { get; set; }
public string Name { get; set; }
}
internal sealed class InstalledGameRecord
{
public Platform Platform { get; set; }
public string Id { get; set; }
public string Name { get; set; }
public string RootDirectory { get; set; }
public InstalledUnlocker Unlocker { get; set; }
public bool UseProxy { get; set; }
public string Proxy { get; set; }
public bool UseExtraProtection { get; set; }
public List<InstalledDlcRecord> Dlc { get; set; } = [];
}
internal static class ProgramData
{
private static readonly string DirectoryPathOld =
@@ -27,6 +61,94 @@ 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 ScanLogPath = Path.Combine(DirectoryPath, "game-scan.log");
internal static readonly string SteamCmdLogPath = Path.Combine(DirectoryPath, "cream-steamcmd.log");
internal static readonly string AppLogPath = Path.Combine(DirectoryPath, "CreamInstaller.log");
internal static event Action<string> OnLogWarning;
internal static event Action<string> OnLogError;
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(ScanLogPath, entry, Encoding.UTF8);
}
catch
{
// ignored — logging must never crash the application
}
}
internal static void LogSteamCmd(string message)
{
try
{
string timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
string entry = $"[{timestamp}] [SteamCMD] {message}{Environment.NewLine}";
lock (LogLock)
File.AppendAllText(SteamCmdLogPath, entry, Encoding.UTF8);
}
catch
{
// ignored — logging must never crash the application
}
}
internal static void LogWarning(string message)
{
try
{
string timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
string entry = $"[{timestamp}] [WARN] {message}{Environment.NewLine}";
lock (LogLock)
File.AppendAllText(AppLogPath, entry, Encoding.UTF8);
}
catch
{
// ignored — logging must never crash the application
}
OnLogWarning?.Invoke(message);
}
internal static void LogError(string message, Exception ex = null)
{
try
{
string timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture);
string entry = ex is not null
? $"[{timestamp}] [ERROR] {message}{Environment.NewLine}[{timestamp}] [ERROR] Exception: {ex}{Environment.NewLine}"
: $"[{timestamp}] [ERROR] {message}{Environment.NewLine}";
lock (LogLock)
File.AppendAllText(AppLogPath, entry, Encoding.UTF8);
}
catch
{
// ignored — logging must never crash the application
}
OnLogError?.Invoke(message);
}
internal static void ClearLog()
{
try
{
if (File.Exists(ScanLogPath))
File.Delete(ScanLogPath);
}
catch
{
// ignored
}
}
internal static async Task Setup(Form form = null)
=> await Task.Run(() =>
@@ -195,4 +317,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);
}
}
+639
View File
@@ -0,0 +1,639 @@
using System;
using System.Drawing;
using System.Linq;
using System.Windows.Forms;
namespace CreamInstaller.Utility;
internal static class ThemeManager
{
// -----------------------------------------------------------------
// Color definitions (do not change values)
// -----------------------------------------------------------------
// ----------------------------
// 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");
// 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
// Badge colors for unlockers
private static readonly Color CreamAPIBadgeBack = ColorTranslator.FromHtml("#C8A078"); // Creamy latte
private static readonly Color CreamAPIBadgeBackHighlight = ColorTranslator.FromHtml("#B48C64");
private static readonly Color CreamAPIBadgeBorder = ColorTranslator.FromHtml("#DCB48C");
private static readonly Color SmokeAPIBadgeBack = ColorTranslator.FromHtml("#69696E"); // Smoky grey
private static readonly Color SmokeAPIBadgeBackHighlight = ColorTranslator.FromHtml("#5A5A5F");
private static readonly Color SmokeAPIBadgeBorder = ColorTranslator.FromHtml("#8C8C91");
private static readonly Color DefaultBadgeBack = ColorTranslator.FromHtml("#008C46"); // Default green
private static readonly Color DefaultBadgeBackHighlight = ColorTranslator.FromHtml("#00783C");
private static readonly Color DefaultBadgeBorder = ColorTranslator.FromHtml("#00B45A");
// ----------------------------
// Light mode colors (system defaults)
// ----------------------------
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;
// 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;
// -----------------------------------------------------------------
// Theme-aware properties used by other components (CustomTreeView etc.)
// -----------------------------------------------------------------
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 (Exception ex) { ProgramData.LogWarning($"[Theme] Title bar theming failed: {ex.Message}"); }
}
private static void TryApplyScrollbarTheme(Control control, bool dark)
{
try
{
string theme = dark ? "DarkMode_Explorer" : null;
NativeImports.SetWindowTheme(control.Handle, theme, null);
}
catch (Exception ex) { ProgramData.LogWarning($"[Theme] Scrollbar theming failed: {ex.Message}"); }
}
// -----------------------------------------------------------------
// 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
{
// DWM attribute index for enabling/disabling the immersive dark title bar.
// Documented in dwmapi.h; value 20 corresponds to DWMWA_USE_IMMERSIVE_DARK_MODE
// (Windows 10 build 19041+ / Windows 11).
private const int DWMWA_USE_IMMERSIVE_DARK_MODE = 20;
// DwmSetWindowAttribute allows setting per-window Desktop Window Manager attributes.
// We use it here to flip the title bar to dark or light depending on the active theme,
// since WinForms has no built-in API to control title bar coloring.
[System.Runtime.InteropServices.DllImport("dwmapi.dll")]
private static extern int DwmSetWindowAttribute(System.IntPtr hwnd, int attr, ref int attrValue, int attrSize);
/// <summary>
/// Toggles the dark/light title bar chrome for the given window handle.
/// Pass <c>1</c> for dark mode, <c>0</c> for light mode.
/// </summary>
internal static void EnableDarkTitleBar(System.IntPtr handle, int useDark)
{
_ = DwmSetWindowAttribute(handle, DWMWA_USE_IMMERSIVE_DARK_MODE, ref useDark, sizeof(int));
}
// Win32 Edit control message that sets or updates the cue (placeholder) banner text.
// WinForms sets PlaceholderText once at creation time via this same message internally,
// but does not re-send it when the control's colors change. When we restyle a TextBox
// for dark/light mode the cue banner can disappear, so we must re-send the message
// manually to make the placeholder visible again.
private const int EM_SETCUEBANNER = 0x1501;
// SendMessage is the standard Win32 mechanism for posting messages directly to a
// window/control handle. We use the Unicode variant so the placeholder string is
// transmitted without any ANSI conversion.
[System.Runtime.InteropServices.DllImport("user32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
private static extern System.IntPtr SendMessage(System.IntPtr hWnd, int msg, System.IntPtr wParam, string lParam);
/// <summary>
/// Re-sends <c>EM_SETCUEBANNER</c> to the given TextBox so its placeholder text
/// is redrawn after a theme change has altered the control's background or foreground colors.
/// Does nothing if the control handle has not yet been created or the placeholder is empty.
/// </summary>
internal static void RefreshCueBanner(System.Windows.Forms.TextBox textBox)
{
if (textBox?.IsHandleCreated == true && textBox.PlaceholderText is { Length: > 0 })
SendMessage(textBox.Handle, EM_SETCUEBANNER, (System.IntPtr)1, textBox.PlaceholderText);
}
}
+88 -15
View File
@@ -1,6 +1,7 @@
### [Forked] CreamInstaller: Automatic DLC Unlocker Installer & Configuration Generator
### [Revived] CreamInstaller: Automatic DLC Unlocker Installer & Configuration Generator
[![Latest Release](https://img.shields.io/github/v/release/FroggMaster/CreamInstaller?label=latest%20release)](https://github.com/FroggMaster/CreamInstaller/releases/latest) [![CI Build](https://github.com/FroggMaster/CreamInstaller/actions/workflows/ci-builds.yml/badge.svg)](https://github.com/FroggMaster/CreamInstaller/actions/workflows/ci-builds.yml)
![Program Preview Image](https://raw.githubusercontent.com/HvTcCore/CreamInstaller/main/preview.png)
![Program Preview Image](https://raw.githubusercontent.com/FroggMaster/CreamInstaller/main/preview.png)
###### **NOTE:** This is simply a preview image; this is not a list of supported games nor configurations!
@@ -26,11 +27,18 @@ games and DLCs the user selects; however, through the use of **right-click conte
* Automatic DLL installation and configuration generation for CreamAPI, Koaloader, ScreamAPI, Uplay R1 Unlocker and Uplay R2 Unlocker.
* Automatic uninstallation of DLLs and configurations for CreamAPI, Koaloader, SmokeAPI, ScreamAPI, Uplay R1 Unlocker and Uplay R2 Unlocker.
* Automatic reparation of the Paradox Launcher (and manually via the right-click context menu "Repair" option). *For when the launcher updates whilst you have CreamAPI, SmokeAPI or ScreamAPI installed to it.*
---
<details>
<summary><strong>Continuous Integration (CI) Builds</strong></summary>
- CreamInstaller is automatically built and tested using GitHub Actions on every push to the **main** branch. You can view all recent CI build runs by clicking the status badge at the top or here: [![CI Build](https://github.com/FroggMaster/CreamInstaller/actions/workflows/ci-builds.yml/badge.svg)](https://github.com/FroggMaster/CreamInstaller/actions/workflows/ci-builds.yml)
</details>
---
#### Installation:
1. Click [here](https://github.com/HvTcCore/CreamInstaller/releases/latest/download/CreamInstaller.zip) to download the latest release from [GitHub](https://github.com/HvtcCore/CreamInstaller).
2. Extract the executable from the ZIP file to anywhere on your computer you want. *It's completely self-contained.*
1. Click [here](https://github.com/FroggMaster/CreamInstaller/releases/latest/download/CreamInstaller.exe) to download the latest release from [GitHub](https://github.com/FroggMaster/CreamInstaller).
2. Move the executable to anywhere on your computer you want. *It's completely self-contained.*
If the program doesn't seem to launch, try downloading and installing [.NET Desktop Runtime 8.0.7](https://download.visualstudio.microsoft.com/download/pr/bb581716-4cca-466e-9857-512e2371734b/5fe261422a7305171866fd7812d0976f/windowsdesktop-runtime-8.0.7-win-x64.exe) and restarting your computer. 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).
@@ -49,26 +57,91 @@ 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/HvTcCore/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/HvTcCore/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 arent unlocking, this is **not an issue with CreamInstaller itself** and isnt 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 youre 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 ($300500/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 Its 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: https://github.com/HvTcCore/CreamInstaller/issues/40.
---
##### Bugs/Crashes/Issues:
For reliable and quick assistance, all bugs, crashes and other issues should be referred to the [GitHub Issues](https://github.com/HvTcCore/CreamInstaller/issues) page!
For reliable and quick assistance, all bugs, crashes and other issues should be referred to the [GitHub Issues](https://github.com/FroggMaster/CreamInstaller/issues) page!
##### **HOWEVER**: Please read the [FAQ entry](https://github.com/HvTcCore/CreamInstaller#faq--common-issues) above and/or [template issue](https://github.com/HvTcCore/CreamInstaller/issues/new/choose) corresponding to your problem should one exist! Also, note that the [GitHub Issues](https://github.com/HvTcCore/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.
##### **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/HvTcCore/CreamInstaller) by choice of the user through a dialog on startup.
* The program source and other information can be found on [GitHub](https://github.com/HvTcCore/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.
* The program source and other information can be found on [GitHub](https://github.com/FroggMaster/CreamInstaller).
* Credit to [Mattahan](https://www.mattahan.com) for the program icon.
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 39 KiB