├── .github
├── FUNDING.yml
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── workflows
│ └── build.yml
├── icon.ico
├── GreenLuma-Manager.slnx
├── App.xaml
├── Models
├── Profile.cs
├── PluginManifest.cs
├── UpdateInfo.cs
├── PluginInfo.cs
├── Config.cs
└── Game.cs
├── Plugins
└── IPlugin.cs
├── AssemblyInfo.cs
├── Services
├── DepotService.cs
├── ConfigService.cs
├── ProfileService.cs
├── UpdateService.cs
├── SteamService.cs
├── PluginService.cs
├── GreenLumaService.cs
├── IconCacheService.cs
└── SearchService.cs
├── LICENSE
├── .gitignore
├── Utilities
├── IconUrlConverter.cs
├── PathDetector.cs
└── AutostartManager.cs
├── GreenLuma-Manager.csproj
├── Dialogs
├── CreateProfileDialog.xaml.cs
├── PluginsDialog.xaml.cs
├── CustomMessageBox.xaml.cs
├── CustomMessageBox.xaml
├── SettingsDialog.xaml.cs
├── CreateProfileDialog.xaml
└── PluginsDialog.xaml
├── CONTRIBUTING.md
├── README.md
└── App.xaml.cs
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [3vil3vo]
2 |
--------------------------------------------------------------------------------
/icon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/3vil3vo/GreenLuma-Manager/HEAD/icon.ico
--------------------------------------------------------------------------------
/GreenLuma-Manager.slnx:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/App.xaml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
--------------------------------------------------------------------------------
/Models/Profile.cs:
--------------------------------------------------------------------------------
1 | using System.Runtime.Serialization;
2 |
3 | namespace GreenLuma_Manager.Models;
4 |
5 | [DataContract]
6 | public class Profile
7 | {
8 | [DataMember] public string Name { get; set; } = "New Profile";
9 |
10 | [DataMember] public List Games { get; set; } = [];
11 | }
--------------------------------------------------------------------------------
/Models/PluginManifest.cs:
--------------------------------------------------------------------------------
1 | namespace GreenLuma_Manager.Models;
2 |
3 | public class PluginManifest
4 | {
5 | public string Name { get; set; } = string.Empty;
6 | public string Version { get; set; } = string.Empty;
7 | public string Author { get; set; } = string.Empty;
8 | public string Description { get; set; } = string.Empty;
9 | }
--------------------------------------------------------------------------------
/Models/UpdateInfo.cs:
--------------------------------------------------------------------------------
1 | namespace GreenLuma_Manager.Models;
2 |
3 | public class UpdateInfo
4 | {
5 | public required string CurrentVersion { get; set; }
6 | public required string LatestVersion { get; set; }
7 | public required string LatestVersionTag { get; set; }
8 | public bool UpdateAvailable { get; set; }
9 | public required string DownloadUrl { get; set; }
10 | public required string ReleaseNotes { get; set; }
11 | }
--------------------------------------------------------------------------------
/Plugins/IPlugin.cs:
--------------------------------------------------------------------------------
1 | using System.Windows;
2 | using System.Windows.Media;
3 |
4 | namespace GreenLuma_Manager.Plugins;
5 |
6 | public interface IPlugin
7 | {
8 | string Name { get; }
9 | string Version { get; }
10 | string Author { get; }
11 | string Description { get; }
12 | Geometry Icon { get; }
13 |
14 | void Initialize();
15 | void OnApplicationStartup();
16 | void OnApplicationShutdown();
17 | void ShowUi(Window owner);
18 | }
--------------------------------------------------------------------------------
/Models/PluginInfo.cs:
--------------------------------------------------------------------------------
1 | using System.Runtime.Serialization;
2 |
3 | namespace GreenLuma_Manager.Models;
4 |
5 | [DataContract]
6 | public class PluginInfo
7 | {
8 | [DataMember] public string Name { get; set; } = string.Empty;
9 | [DataMember] public string Version { get; set; } = string.Empty;
10 | [DataMember] public string Author { get; set; } = string.Empty;
11 | [DataMember] public string Description { get; set; } = string.Empty;
12 | [DataMember] public string FileName { get; set; } = string.Empty;
13 | [DataMember] public bool IsEnabled { get; set; } = true;
14 | [DataMember] public string Id { get; set; } = string.Empty;
15 | }
--------------------------------------------------------------------------------
/AssemblyInfo.cs:
--------------------------------------------------------------------------------
1 | using System.Windows;
2 |
3 | [assembly: ThemeInfo(
4 | ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located
5 | //(used if a resource is not found in the page,
6 | // or application resource dictionaries)
7 | ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located
8 | //(used if a resource is not found in the page,
9 | // app, or any theme specific resource dictionaries)
10 | )]
11 |
--------------------------------------------------------------------------------
/Services/DepotService.cs:
--------------------------------------------------------------------------------
1 | namespace GreenLuma_Manager.Services;
2 |
3 | public class AppPackageInfo
4 | {
5 | public string AppId { get; set; } = string.Empty;
6 | public List Depots { get; set; } = [];
7 | public List DlcAppIds { get; set; } = [];
8 | public Dictionary> DlcDepots { get; set; } = [];
9 | }
10 |
11 | public static class DepotService
12 | {
13 | public static async Task FetchAppPackageInfoAsync(string appId)
14 | {
15 | if (!uint.TryParse(appId, out var id))
16 | return null;
17 |
18 | return await SteamService.Instance.GetAppPackageInfoAsync(id);
19 | }
20 | }
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: "[BUG]"
5 | labels: bug
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Additional context**
27 | Add any other context about the problem here.
28 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: "[REQUEST]"
5 | labels: enhancement
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/Models/Config.cs:
--------------------------------------------------------------------------------
1 | using System.Runtime.Serialization;
2 |
3 | namespace GreenLuma_Manager.Models;
4 |
5 | [DataContract]
6 | public class Config
7 | {
8 | [DataMember] public string SteamPath { get; set; } = string.Empty;
9 |
10 | [DataMember] public string GreenLumaPath { get; set; } = string.Empty;
11 |
12 | [DataMember] public bool NoHook { get; set; }
13 |
14 | [DataMember] public bool DisableUpdateCheck { get; set; }
15 |
16 | [DataMember] public bool AutoUpdate { get; set; } = true;
17 |
18 | [DataMember] public string LastProfile { get; set; } = "default";
19 |
20 | [DataMember] public bool CheckUpdate { get; set; } = true;
21 |
22 | [DataMember] public bool ReplaceSteamAutostart { get; set; }
23 |
24 | [DataMember] public bool FirstRun { get; set; } = true;
25 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 3vil3vo
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Build results
2 | [Dd]ebug/
3 | [Dd]ebugPublic/
4 | [Rr]elease/
5 | [Rr]eleases/
6 | x64/
7 | x86/
8 | [Ww][Ii][Nn]32/
9 | [Aa][Rr][Mm]/
10 | [Aa][Rr][Mm]64/
11 | bld/
12 | [Bb]in/
13 | [Vv]sbin/
14 | [Oo]bj/
15 | [Ll]og/
16 | [Ll]ogs/
17 |
18 | # Visual Studio cache/options
19 | .vs/
20 | .vscode/
21 | *.suo
22 | *.user
23 | *.userosscache
24 | *.sln.docstates
25 |
26 | # NuGet Packages
27 | *.nupkg
28 | *.snupkg
29 | **/packages/*
30 | !**/packages/build/
31 | *.nuget.props
32 | *.nuget.targets
33 |
34 | # Visual Studio profiler
35 | *.psess
36 | *.vsp
37 | *.vspx
38 | *.sap
39 |
40 | # ReSharper
41 | _ReSharper*/
42 | *.[Rr]e[Ss]harper
43 | *.DotSettings.user
44 |
45 | # Build results
46 | [Bb]uild[Ll]og.*
47 |
48 | # MSTest test Results
49 | [Tt]est[Rr]esult*/
50 | [Bb]uild[Ll]og.*
51 |
52 | # Files built by Visual Studio
53 | *_i.c
54 | *_p.c
55 | *_h.h
56 | *.ilk
57 | *.meta
58 | *.obj
59 | *.iobj
60 | *.pch
61 | *.pdb
62 | *.ipdb
63 | *.pgc
64 | *.pgd
65 | *.rsp
66 | *.sbr
67 | *.tlb
68 | *.tli
69 | *.tlh
70 | *.tmp
71 | *.tmp_proj
72 | *_wpftmp.csproj
73 | *.log
74 | *.tlog
75 | *.vspscc
76 | *.vssscc
77 | .builds
78 | *.pidb
79 | *.svclog
80 | *.scc
81 |
82 | # Backup & report files
83 | _UpgradeReport_Files/
84 | Backup*/
85 | UpgradeLog*.XML
86 | UpgradeLog*.htm
87 | ServiceFabricBackup/
88 | *.rptproj.bak
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: Build Release
2 |
3 | on:
4 | push:
5 | tags:
6 | - "v*"
7 | workflow_dispatch:
8 |
9 | permissions:
10 | contents: write
11 |
12 | jobs:
13 | build:
14 | runs-on: windows-latest
15 |
16 | steps:
17 | - uses: actions/checkout@v4
18 |
19 | - name: Setup .NET
20 | uses: actions/setup-dotnet@v4
21 | with:
22 | dotnet-version: "10.0.x"
23 |
24 | - name: Restore dependencies
25 | run: dotnet restore GreenLuma-Manager.csproj
26 |
27 | - name: Publish single-file executable
28 | run: dotnet publish GreenLuma-Manager.csproj -r win-x64 -p:PublishSingleFile=true -p:SelfContained=false -c Release -o dist
29 |
30 | - name: Upload artifact
31 | uses: actions/upload-artifact@v4
32 | with:
33 | name: GreenLuma-Manager
34 | path: dist/GreenLuma-Manager.exe
35 |
36 | - name: Get tag name
37 | if: startsWith(github.ref, 'refs/tags/')
38 | id: tag
39 | run: echo "TAG_NAME=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT
40 | shell: bash
41 |
42 | - name: Create Release
43 | if: startsWith(github.ref, 'refs/tags/')
44 | uses: softprops/action-gh-release@v2
45 | with:
46 | files: dist/GreenLuma-Manager.exe
47 | generate_release_notes: true
48 | draft: false
49 | prerelease: false
50 | env:
51 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
52 |
--------------------------------------------------------------------------------
/Utilities/IconUrlConverter.cs:
--------------------------------------------------------------------------------
1 | using System.Globalization;
2 | using System.IO;
3 | using System.Windows.Data;
4 | using System.Windows.Media.Imaging;
5 |
6 | namespace GreenLuma_Manager.Utilities;
7 |
8 | public class IconUrlConverter : IValueConverter
9 | {
10 | public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
11 | {
12 | if (value is not string iconUrl || string.IsNullOrWhiteSpace(iconUrl))
13 | return null;
14 | try
15 | {
16 | if (iconUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase))
17 | {
18 | var bmp = new BitmapImage();
19 | bmp.BeginInit();
20 | bmp.UriSource = new Uri(iconUrl, UriKind.Absolute);
21 | bmp.CacheOption = BitmapCacheOption.OnDemand;
22 | bmp.CreateOptions = BitmapCreateOptions.IgnoreColorProfile;
23 | bmp.EndInit();
24 | return bmp;
25 | }
26 |
27 | if (File.Exists(iconUrl))
28 | {
29 | var bmp = new BitmapImage();
30 | bmp.BeginInit();
31 | bmp.UriSource = new Uri(iconUrl, UriKind.Absolute);
32 | bmp.CacheOption = BitmapCacheOption.OnLoad;
33 | bmp.CreateOptions = BitmapCreateOptions.IgnoreColorProfile;
34 | bmp.EndInit();
35 | bmp.Freeze();
36 | return bmp;
37 | }
38 |
39 | return null;
40 | }
41 | catch
42 | {
43 | return null;
44 | }
45 | }
46 |
47 | public object ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
48 | {
49 | throw new NotImplementedException();
50 | }
51 | }
--------------------------------------------------------------------------------
/Models/Game.cs:
--------------------------------------------------------------------------------
1 | using System.ComponentModel;
2 | using System.Runtime.Serialization;
3 |
4 | namespace GreenLuma_Manager.Models;
5 |
6 | [DataContract]
7 | public class Game : INotifyPropertyChanged
8 | {
9 | [DataMember] public required string AppId { get; set; }
10 |
11 | [DataMember]
12 | public required string Name
13 | {
14 | get;
15 | set
16 | {
17 | if (field != value)
18 | {
19 | field = value;
20 | OnPropertyChanged(nameof(Name));
21 | }
22 | }
23 | } = string.Empty;
24 |
25 | [DataMember]
26 | public required string Type
27 | {
28 | get;
29 | set
30 | {
31 | if (field != value)
32 | {
33 | field = value;
34 | OnPropertyChanged(nameof(Type));
35 | }
36 | }
37 | }
38 |
39 | [DataMember]
40 | public string IconUrl
41 | {
42 | get;
43 | set
44 | {
45 | if (field != value)
46 | {
47 | field = value;
48 | OnPropertyChanged(nameof(IconUrl));
49 | }
50 | }
51 | } = string.Empty;
52 |
53 | [DataMember] public List Depots { get; set; } = [];
54 |
55 | [IgnoreDataMember]
56 | public bool IsEditing
57 | {
58 | get;
59 | set
60 | {
61 | if (field != value)
62 | {
63 | field = value;
64 | OnPropertyChanged(nameof(IsEditing));
65 | }
66 | }
67 | }
68 |
69 | public event PropertyChangedEventHandler? PropertyChanged;
70 |
71 | protected void OnPropertyChanged(string propertyName)
72 | {
73 | PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
74 | }
75 | }
--------------------------------------------------------------------------------
/GreenLuma-Manager.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | WinExe
5 | net10.0-windows
6 | GreenLuma-Manager
7 | GreenLuma_Manager
8 | enable
9 | enable
10 | true
11 | icon.ico
12 | 1.0.0-rc2.11
13 | 0.0.0.2
14 | 0.0.0.2
15 | Copyright © 3vil3vo 2025
16 | icon.ico
17 | README.md
18 | https://github.com/3vil3vo/GreenLuma-Manager
19 | LICENSE
20 | False
21 | x64
22 | Debug;Release
23 | AnyCPU
24 | true
25 | false
26 | win-x64
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 | True
41 | \
42 |
43 |
44 | True
45 | \
46 |
47 |
48 | True
49 | \
50 |
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/Dialogs/CreateProfileDialog.xaml.cs:
--------------------------------------------------------------------------------
1 | using System.IO;
2 | using System.Windows;
3 | using System.Windows.Input;
4 | using GreenLuma_Manager.Models;
5 |
6 | namespace GreenLuma_Manager.Dialogs;
7 |
8 | public partial class CreateProfileDialog
9 | {
10 | public CreateProfileDialog()
11 | {
12 | InitializeComponent();
13 | Result = null;
14 | TxtProfileName.Focus();
15 | PreviewKeyDown += OnPreviewKeyDown;
16 | }
17 |
18 | public Profile? Result { get; private set; }
19 |
20 | private void OnPreviewKeyDown(object sender, KeyEventArgs e)
21 | {
22 | if (e.Key == Key.Escape) Cancel_Click(sender, null);
23 | }
24 |
25 | private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
26 | {
27 | if (e.LeftButton == MouseButtonState.Pressed) DragMove();
28 | }
29 |
30 | private void ProfileName_PreviewTextInput(object sender, TextCompositionEventArgs e)
31 | {
32 | e.Handled = !IsValidProfileNameCharacter(e.Text);
33 | }
34 |
35 | private static bool IsValidProfileNameCharacter(string text)
36 | {
37 | return text.All(c => char.IsLetterOrDigit(c) || c == ' ' || c == '-' || c == '_');
38 | }
39 |
40 | private void ProfileName_KeyDown(object sender, KeyEventArgs e)
41 | {
42 | switch (e.Key)
43 | {
44 | case Key.Return:
45 | Ok_Click(sender, null);
46 | break;
47 | case Key.Escape:
48 | Cancel_Click(sender, null);
49 | break;
50 | }
51 | }
52 |
53 | private void Ok_Click(object sender, RoutedEventArgs? e)
54 | {
55 | var profileName = TxtProfileName.Text.Trim();
56 |
57 | if (!ValidateProfileName(profileName))
58 | return;
59 |
60 | Result = new Profile { Name = profileName };
61 | DialogResult = true;
62 | Close();
63 | }
64 |
65 | private static bool ValidateProfileName(string? profileName)
66 | {
67 | if (string.IsNullOrWhiteSpace(profileName))
68 | {
69 | CustomMessageBox.Show(
70 | "Profile name cannot be empty.",
71 | "Validation",
72 | icon: MessageBoxImage.Exclamation);
73 | return false;
74 | }
75 |
76 | if (profileName.Length > 50)
77 | {
78 | CustomMessageBox.Show(
79 | "Profile name is too long (max 50 characters).",
80 | "Validation",
81 | icon: MessageBoxImage.Exclamation);
82 | return false;
83 | }
84 |
85 | if (ContainsInvalidCharacters(profileName))
86 | {
87 | CustomMessageBox.Show(
88 | "Profile name contains invalid characters.",
89 | "Validation",
90 | icon: MessageBoxImage.Exclamation);
91 | return false;
92 | }
93 |
94 | return true;
95 | }
96 |
97 | private static bool ContainsInvalidCharacters(string profileName)
98 | {
99 | return profileName.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0 ||
100 | profileName.Contains('/') ||
101 | profileName.Contains('\\');
102 | }
103 |
104 | private void Cancel_Click(object sender, RoutedEventArgs? e)
105 | {
106 | DialogResult = false;
107 | Close();
108 | }
109 | }
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing to GreenLuma Manager
2 |
3 | Thank you for your interest in contributing to GreenLuma Manager!
4 |
5 | ## Development Setup
6 |
7 | 1. **Fork and clone the repository**
8 | ```bash
9 | git clone https://github.com/YOUR_USERNAME/GreenLuma-Manager.git
10 | cd GreenLuma-Manager
11 | ```
12 |
13 | 2. **Open in Visual Studio**
14 | - Open `GreenLuma-Manager.slnx` in Visual Studio 2022 or later
15 | - Ensure you have .NET 10.0 SDK installed
16 |
17 | 3. **Restore NuGet packages**
18 | - Right-click the solution in Solution Explorer
19 | - Select "Restore NuGet Packages"
20 | - Package: Newtonsoft.Json
21 |
22 | 4. **Build and run**
23 | - Press F5 to build and run in debug mode
24 | - Or Build → Build Solution (Ctrl+Shift+B)
25 |
26 | ## Project Structure
27 |
28 | - `Services/` - Core application services
29 | - `ConfigService.cs` - Configuration management and RC3 migration
30 | - `ProfileService.cs` - Profile management and RC3 migration
31 | - `SearchService.cs` - Steam game search and icon fetching
32 | - `GreenLumaService.cs` - AppList generation and GreenLuma launch
33 | - `UpdateService.cs` - Auto-update functionality
34 | - `IconCacheService.cs` - Icon caching and management
35 | - `Models/` - Data models
36 | - `Config.cs` - Application configuration model
37 | - `Profile.cs` - Game profile model
38 | - `Game.cs` - Game data with INotifyPropertyChanged
39 | - `UpdateInfo.cs` - Update information model
40 | - `Dialogs/` - WPF dialog windows
41 | - `SettingsDialog.xaml` - Settings UI
42 | - `CreateProfileDialog.xaml` - Profile creation UI
43 | - `CustomMessageBox.xaml` - Custom message boxes
44 | - `Utilities/` - Helper classes
45 | - `PathDetector.cs` - Auto-detection of Steam/GreenLuma paths
46 | - `IconUrlConverter.cs` - WPF value converter for icons
47 | - `AutostartManager.cs` - Windows startup integration
48 | - `MainWindow.xaml` - Main application window
49 |
50 | ## Code Style
51 |
52 | - Follow C# naming conventions (PascalCase for public members, camelCase for private)
53 | - Use `async`/`await` for asynchronous operations
54 | - Implement `INotifyPropertyChanged` for data-bound properties
55 | - Keep methods focused and single-purpose
56 | - Use meaningful variable and method names
57 | - Add XML documentation comments for public APIs
58 |
59 | ## WPF Best Practices
60 |
61 | - Use MVVM patterns where appropriate
62 | - Leverage data binding over code-behind manipulation
63 | - Use `SynchronizationContext` for UI thread marshaling
64 | - Implement proper resource cleanup in `Dispose` methods
65 | - Use value converters for data transformation in bindings
66 |
67 | ## Pull Request Process
68 |
69 | 1. Create a new branch for your feature
70 | 2. Make your changes following code style guidelines
71 | 3. Test thoroughly in both Debug and Release builds
72 | 4. Commit with clear, descriptive messages
73 | 5. Push to your fork
74 | 6. Open a Pull Request with a clear description
75 |
76 | ## Testing
77 |
78 | Before submitting:
79 | - Build in both Debug and Release configurations
80 | - Test all modified features
81 | - Verify no binding errors in debug output
82 | - Test with both fresh install and RC3 migration scenarios
83 | - Check for memory leaks with long-running operations
84 | - Verify icon loading works correctly
85 |
86 | ## Reporting Bugs
87 |
88 | Use the GitHub Issues tab and include:
89 | - Clear description of the bug
90 | - Steps to reproduce
91 | - Expected vs actual behavior
92 | - Screenshots if applicable
93 | - Your Windows version
94 | - Application version
95 |
96 | ## Feature Requests
97 |
98 | Open an issue with:
99 | - Clear description of the feature
100 | - Why it would be useful
101 | - Any implementation ideas
102 | - Potential impact on existing features
103 |
104 | ## Questions?
105 |
106 | Feel free to open a discussion or issue for any questions!
107 |
--------------------------------------------------------------------------------
/Dialogs/PluginsDialog.xaml.cs:
--------------------------------------------------------------------------------
1 | using System.Windows;
2 | using System.Windows.Controls;
3 | using System.Windows.Controls.Primitives;
4 | using System.Windows.Input;
5 | using GreenLuma_Manager.Models;
6 | using GreenLuma_Manager.Services;
7 | using Microsoft.Win32;
8 |
9 | namespace GreenLuma_Manager.Dialogs;
10 |
11 | public partial class PluginsDialog
12 | {
13 | public PluginsDialog()
14 | {
15 | InitializeComponent();
16 | LoadPlugins();
17 | PreviewKeyDown += OnPreviewKeyDown;
18 | }
19 |
20 | private void OnPreviewKeyDown(object sender, KeyEventArgs e)
21 | {
22 | if (e.Key == Key.Escape) Close();
23 | }
24 |
25 | private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
26 | {
27 | if (e.LeftButton == MouseButtonState.Pressed) DragMove();
28 | }
29 |
30 | private void LoadPlugins()
31 | {
32 | var plugins = PluginService.GetAllPlugins();
33 | LstPlugins.ItemsSource = plugins;
34 |
35 | PnlEmptyPlugins.Visibility = plugins.Count == 0 ? Visibility.Visible : Visibility.Collapsed;
36 | }
37 |
38 | private void ImportButton_Click(object sender, RoutedEventArgs e)
39 | {
40 | var dialog = new OpenFileDialog
41 | {
42 | Title = "Select Plugin DLL",
43 | Filter = "Plugin Files (*.dll)|*.dll|All Files (*.*)|*.*",
44 | Multiselect = false
45 | };
46 |
47 | if (dialog.ShowDialog() != true) return;
48 |
49 | var error = PluginService.ImportPlugin(dialog.FileName);
50 | if (!string.IsNullOrEmpty(error))
51 | {
52 | CustomMessageBox.Show(error, "Import Failed", icon: MessageBoxImage.Error);
53 | return;
54 | }
55 |
56 | CustomMessageBox.Show(
57 | "Plugin imported successfully. Restart the application to load the plugin.",
58 | "Success",
59 | icon: MessageBoxImage.Asterisk);
60 |
61 | LoadPlugins();
62 |
63 | if (Owner is MainWindow mainWindow)
64 | mainWindow.UpdatePluginButtons();
65 | }
66 |
67 | private void RemoveButton_Click(object sender, RoutedEventArgs e)
68 | {
69 | if (sender is not Button button || button.Tag is not PluginInfo plugin) return;
70 |
71 | var result = CustomMessageBox.Show(
72 | $"Remove plugin '{plugin.Name}'?\n\nThis will delete the plugin file and cannot be undone.",
73 | "Confirm Remove",
74 | MessageBoxButton.YesNo,
75 | MessageBoxImage.Warning);
76 |
77 | if (result != MessageBoxResult.Yes) return;
78 |
79 | PluginService.RemovePlugin(plugin);
80 | LoadPlugins();
81 |
82 | CustomMessageBox.Show("Plugin removed successfully.", "Success", icon: MessageBoxImage.Asterisk);
83 |
84 | if (Owner is MainWindow mainWindow)
85 | mainWindow.UpdatePluginButtons();
86 | }
87 |
88 | private void PluginToggle_Changed(object sender, RoutedEventArgs e)
89 | {
90 | if (sender is not ToggleButton toggle || toggle.Tag is not PluginInfo plugin)
91 | return;
92 |
93 | var enabled = toggle.IsChecked.GetValueOrDefault();
94 | PluginService.TogglePlugin(plugin, enabled);
95 |
96 | if (enabled)
97 | CustomMessageBox.Show(
98 | $"Plugin '{plugin.Name}' enabled. Restart the application to load the plugin.",
99 | "Plugin Enabled",
100 | icon: MessageBoxImage.Asterisk);
101 | else
102 | CustomMessageBox.Show(
103 | $"Plugin '{plugin.Name}' disabled. Restart the application to unload the plugin.",
104 | "Plugin Disabled",
105 | icon: MessageBoxImage.Asterisk);
106 |
107 | if (Owner is MainWindow mainWindow)
108 | mainWindow.UpdatePluginButtons();
109 | }
110 |
111 | private void CloseButton_Click(object sender, RoutedEventArgs e)
112 | {
113 | Close();
114 | }
115 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # GreenLuma Manager
2 |
3 | A modern desktop app for managing your GreenLuma AppList. No more entering app IDs one by one - just search, click, and launch.
4 |
5 | 
6 | 
7 | 
8 |
9 | ## Features
10 |
11 | - **Smart Search** - Find any Steam game or DLC instantly with real-time results from Steam's API
12 | - **Profile Management** - Keep different game lists organized with multiple profiles
13 | - **Auto-Detection** - Automatically finds your Steam and GreenLuma folders
14 | - **One-Click Launch** - Generate your AppList and fire up GreenLuma without the hassle
15 | - **Modern UI** - A clean WPF interface built with MaterialDesign
16 | - **Stealth Mode** - Configure GreenLuma's injection settings for discrete operation
17 | - **Auto-Updates** - Keeps you up to date with the latest features and fixes
18 | - **Auto-Start** - Option to launch with Windows and replace Steam startup
19 |
20 | ## Getting Started
21 |
22 | ### Download and Run
23 |
24 | 1. Grab the latest `GreenLuma-Manager.exe` from [Releases](../../releases)
25 | 2. Double-click and run it
26 | 3. The app will auto-detect your Steam and GreenLuma paths
27 | 4. If it doesn't find them, set them manually in Settings (⚙️)
28 |
29 | ### Building from Source
30 |
31 | #### Prerequisites
32 | - Visual Studio 2022 or later
33 | - .NET 10.0 or higher
34 |
35 | #### Steps
36 |
37 | 1. **Clone the repository**
38 | ```bash
39 | git clone https://github.com/3vil3vo/GreenLuma-Manager.git
40 | cd GreenLuma-Manager
41 | ```
42 |
43 | 2. **Open in Visual Studio**
44 | - Open `GreenLuma-Manager.slnx`
45 |
46 | 3. **Restore NuGet packages**
47 | - Right-click solution → Restore NuGet Packages
48 |
49 | 4. **Build the project**
50 | - Build → Build Solution (Ctrl+Shift+B)
51 | - Find the executable in `bin/Debug/` or `bin/Release/`
52 |
53 | ## How to Use
54 |
55 | 1. **First Time Setup**
56 | - On first launch, the app auto-detects Steam and GreenLuma paths
57 | - If migrating from RC3, your settings and profiles are automatically imported
58 | - Adjust settings in Settings (⚙️) if needed
59 |
60 | 2. **Finding Games**
61 | - Type a game name or AppID into the search box
62 | - Results appear instantly from Steam's database with icons
63 | - Click the + button to add games to your current profile
64 |
65 | 3. **Managing Profiles**
66 | - Select a profile from the dropdown menu
67 | - Create new profiles with the + button
68 | - Add games with +, remove with the delete button
69 | - Each profile is saved automatically
70 |
71 | 4. **Launching GreenLuma**
72 | - Click "Generate AppList" to write files to your GreenLuma folder
73 | - Hit "Launch GreenLuma" - the app will close Steam and start GreenLuma
74 | - Enable stealth mode in settings for discrete injection
75 |
76 | ## Requirements
77 |
78 | - Windows 10/11
79 | - .NET 10.0 or higher
80 | - Steam installed
81 | - GreenLuma 2025 installed
82 |
83 | ## Project Structure
84 |
85 | - `Services/` - Core services (Config, Profile, Search, GreenLuma, Update, IconCache)
86 | - `Models/` - Data models (Config, Profile, Game, UpdateInfo)
87 | - `Dialogs/` - WPF dialogs (Settings, Create Profile, Message Box)
88 | - `Utilities/` - Helper classes (Path detection, Icon converter, Autostart)
89 |
90 | ## Special Thanks
91 |
92 | Shoutout to [BlueAmulet's GreenLuma-2025-Manager](https://github.com/BlueAmulet/GreenLuma-2025-Manager) for inspiration.
93 |
94 | ## Contributing
95 |
96 | Found a bug? Want to add a feature? Pull requests are always welcome! Check [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
97 |
98 | ## License
99 |
100 | MIT License - See [LICENSE](LICENSE) for details.
101 |
102 | ## Disclaimer
103 |
104 | This is an educational project. Use it responsibly and at your own risk. We're not responsible if something goes wrong.
105 |
106 | ## Author
107 |
108 | Built with ☕ by [3vil3vo](https://github.com/3vil3vo)
109 |
110 | ## Need Help?
111 |
112 | Ran into an issue or have an idea? [Open an issue](../../issues) and let's fix it!
113 |
--------------------------------------------------------------------------------
/Services/ConfigService.cs:
--------------------------------------------------------------------------------
1 | using System.IO;
2 | using System.Text;
3 | using System.Text.Json;
4 | using GreenLuma_Manager.Models;
5 | using GreenLuma_Manager.Utilities;
6 | using Newtonsoft.Json.Linq;
7 |
8 | namespace GreenLuma_Manager.Services;
9 |
10 | public class ConfigService
11 | {
12 | private static readonly string ConfigDir = Path.Combine(
13 | Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
14 | "GLM_Manager");
15 |
16 | private static readonly string ConfigPath = Path.Combine(ConfigDir, "config.json");
17 |
18 |
19 | public static Config Load()
20 | {
21 | try
22 | {
23 | EnsureConfigDirectoryExists();
24 |
25 | if (!File.Exists(ConfigPath)) return CreateDefaultConfig();
26 |
27 | var configJson = File.ReadAllText(ConfigPath, Encoding.UTF8);
28 |
29 | var migratedConfig = TryMigrateFromOldVersion(configJson);
30 | if (migratedConfig != null) return migratedConfig;
31 |
32 | return DeserializeConfig(configJson) ?? new Config();
33 | }
34 | catch
35 | {
36 | return new Config();
37 | }
38 | }
39 |
40 | private static void EnsureConfigDirectoryExists()
41 | {
42 | if (!Directory.Exists(ConfigDir)) Directory.CreateDirectory(ConfigDir);
43 | }
44 |
45 | private static Config CreateDefaultConfig()
46 | {
47 | var config = new Config();
48 | var (steamPath, greenLumaPath) = PathDetector.DetectPaths();
49 |
50 | config.SteamPath = steamPath;
51 | config.GreenLumaPath = greenLumaPath;
52 |
53 | Save(config);
54 | return config;
55 | }
56 |
57 | private static Config? DeserializeConfig(string json)
58 | {
59 | try
60 | {
61 | return JsonSerializer.Deserialize(json);
62 | }
63 | catch
64 | {
65 | return null;
66 | }
67 | }
68 |
69 | private static Config? TryMigrateFromOldVersion(string configJson)
70 | {
71 | try
72 | {
73 | var jsonData = JObject.Parse(configJson);
74 |
75 | if (jsonData["steam_path"] == null && jsonData["SteamPath"] != null) return null;
76 |
77 | if (jsonData["steam_path"] != null)
78 | {
79 | var config = new Config
80 | {
81 | SteamPath = jsonData["steam_path"]?.ToString() ?? string.Empty,
82 | GreenLumaPath = jsonData["greenluma_path"]?.ToString() ?? string.Empty,
83 | NoHook = jsonData["no_hook"]?.ToObject() ?? false,
84 | DisableUpdateCheck = jsonData["disable_update_check"]?.ToObject() ?? false,
85 | AutoUpdate = jsonData["auto_update"]?.ToObject() ?? true,
86 | LastProfile = jsonData["last_profile"]?.ToString() ?? "default",
87 | CheckUpdate = jsonData["check_update"]?.ToObject() ?? true,
88 | ReplaceSteamAutostart = jsonData["replace_steam_autostart"]?.ToObject() ?? false,
89 | FirstRun = false
90 | };
91 |
92 | SerializeConfig(config);
93 | return config;
94 | }
95 |
96 | return null;
97 | }
98 | catch
99 | {
100 | return null;
101 | }
102 | }
103 |
104 | public static void Save(Config config)
105 | {
106 | try
107 | {
108 | EnsureConfigDirectoryExists();
109 |
110 | var json = SerializeConfig(config);
111 | File.WriteAllText(ConfigPath, json, Encoding.UTF8);
112 | }
113 | catch
114 | {
115 | // ignored
116 | }
117 | }
118 |
119 | private static string SerializeConfig(Config config)
120 | {
121 | return JsonSerializer.Serialize(config);
122 | }
123 |
124 | public static void WipeData()
125 | {
126 | try
127 | {
128 | AutostartManager.CleanupAll();
129 |
130 | if (Directory.Exists(ConfigDir)) Directory.Delete(ConfigDir, true);
131 | }
132 | catch
133 | {
134 | // ignored
135 | }
136 | }
137 | }
--------------------------------------------------------------------------------
/Utilities/PathDetector.cs:
--------------------------------------------------------------------------------
1 | using System.IO;
2 | using Microsoft.Win32;
3 |
4 | namespace GreenLuma_Manager.Utilities;
5 |
6 | public class PathDetector
7 | {
8 | private static readonly string[] GreenLumaSignatureFiles =
9 | [
10 | "DLLInjector.exe",
11 | "GreenLuma_2025_x64.dll",
12 | "GreenLuma_2025_x86.dll",
13 | "GreenLuma_2024_x86.dll",
14 | "GreenLuma_2023_x86.dll"
15 | ];
16 |
17 | public static (string SteamPath, string GreenLumaPath) DetectPaths()
18 | {
19 | var steamPath = DetectSteamPath();
20 | var greenLumaPath = DetectGreenLumaPath(steamPath);
21 |
22 | return (steamPath, greenLumaPath);
23 | }
24 |
25 | public static string DetectSteamPath()
26 | {
27 | var registryPath = TryDetectSteamFromRegistry();
28 | if (!string.IsNullOrEmpty(registryPath))
29 | return registryPath;
30 |
31 | return TryDetectSteamFromCommonLocations();
32 | }
33 |
34 | private static string TryDetectSteamFromRegistry()
35 | {
36 | var path = TryGetRegistryPath("SOFTWARE\\WOW6432Node\\Valve\\Steam");
37 | if (!string.IsNullOrEmpty(path))
38 | return path;
39 |
40 | path = TryGetRegistryPath("SOFTWARE\\Valve\\Steam");
41 | if (!string.IsNullOrEmpty(path))
42 | return path;
43 |
44 | return string.Empty;
45 | }
46 |
47 | private static string? TryGetRegistryPath(string keyPath)
48 | {
49 | try
50 | {
51 | using var key = Registry.LocalMachine.OpenSubKey(keyPath);
52 | var installPath = key?.GetValue("InstallPath") as string;
53 |
54 | if (!string.IsNullOrWhiteSpace(installPath) &&
55 | File.Exists(Path.Combine(installPath, "Steam.exe")))
56 | return installPath;
57 | }
58 | catch
59 | {
60 | // ignored
61 | }
62 |
63 | return null;
64 | }
65 |
66 | private static string TryDetectSteamFromCommonLocations()
67 | {
68 | string[] commonPaths =
69 | [
70 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Steam"),
71 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Steam"),
72 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Steam")
73 | ];
74 |
75 | foreach (var path in commonPaths)
76 | if (File.Exists(Path.Combine(path, "Steam.exe")))
77 | return path;
78 |
79 | return string.Empty;
80 | }
81 |
82 | public static string DetectGreenLumaPath(string steamPath)
83 | {
84 | if (!string.IsNullOrWhiteSpace(steamPath) && Directory.Exists(steamPath))
85 | if (ContainsGreenLumaFiles(steamPath))
86 | return steamPath;
87 |
88 | return TryDetectGreenLumaFromCommonLocations();
89 | }
90 |
91 | private static string TryDetectGreenLumaFromCommonLocations()
92 | {
93 | string[] commonPaths =
94 | [
95 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "Steam"),
96 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles), "Steam"),
97 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86), "GreenLuma"),
98 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "GreenLuma"),
99 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), "GreenLuma"),
100 | "C:\\GreenLuma",
101 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads", "GreenLuma")
102 | ];
103 |
104 | foreach (var path in commonPaths)
105 | if (Directory.Exists(path) && ContainsGreenLumaFiles(path))
106 | return path;
107 |
108 | return string.Empty;
109 | }
110 |
111 | private static bool ContainsGreenLumaFiles(string directory)
112 | {
113 | foreach (var fileName in GreenLumaSignatureFiles)
114 | if (File.Exists(Path.Combine(directory, fileName)))
115 | return true;
116 |
117 | return false;
118 | }
119 | }
--------------------------------------------------------------------------------
/Utilities/AutostartManager.cs:
--------------------------------------------------------------------------------
1 | using System.IO;
2 | using GreenLuma_Manager.Models;
3 | using Microsoft.Win32;
4 |
5 | namespace GreenLuma_Manager.Utilities;
6 |
7 | public class AutostartManager
8 | {
9 | private const string RunKeyPath = "SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Run";
10 | private const string BackupKeyPath = "SOFTWARE\\GLM_Manager";
11 | private const string GreenLumaValueName = "GreenLumaManager";
12 | private const string GreenLumaMonitorValueName = "GreenLumaMonitor";
13 |
14 | public static void ManageAutostart(bool replaceSteam, Config? config)
15 | {
16 | try
17 | {
18 | using var runKey = Registry.CurrentUser.OpenSubKey(RunKeyPath, true);
19 |
20 | if (runKey == null)
21 | return;
22 |
23 | if (replaceSteam && !string.IsNullOrWhiteSpace(config?.GreenLumaPath))
24 | ReplaceWithGreenLuma(runKey, config);
25 | else
26 | RestoreOriginalSteam(runKey, config?.GreenLumaPath);
27 | }
28 | catch
29 | {
30 | // ignored
31 | }
32 | }
33 |
34 | private static void ReplaceWithGreenLuma(RegistryKey runKey, Config? config)
35 | {
36 | if (config == null)
37 | return;
38 |
39 | var appPath = Environment.ProcessPath ??
40 | Path.Combine(AppContext.BaseDirectory, AppDomain.CurrentDomain.FriendlyName);
41 |
42 | if (string.IsNullOrWhiteSpace(appPath))
43 | return;
44 |
45 | runKey.SetValue(GreenLumaMonitorValueName, $"\"{appPath}\" --launch-greenluma");
46 | }
47 |
48 | private static void RestoreOriginalSteam(RegistryKey runKey, string? greenlumaPath)
49 | {
50 | runKey.DeleteValue(GreenLumaMonitorValueName, false);
51 | CleanupVbsScript(greenlumaPath);
52 | }
53 |
54 | private static void CleanupVbsScript(string? greenlumaPath)
55 | {
56 | try
57 | {
58 | if (!string.IsNullOrWhiteSpace(greenlumaPath))
59 | {
60 | var vbsPath = Path.Combine(greenlumaPath, "GLM_Autostart.vbs");
61 | if (File.Exists(vbsPath)) File.Delete(vbsPath);
62 | }
63 | }
64 | catch
65 | {
66 | // ignored
67 | }
68 | }
69 |
70 | public static void CleanupAll()
71 | {
72 | try
73 | {
74 | RemoveGreenLumaAutostart();
75 | RemoveGreenLumaMonitor();
76 | DeleteBackupKey();
77 | CleanupAllVbsScripts();
78 | }
79 | catch
80 | {
81 | // ignored
82 | }
83 | }
84 |
85 | private static void CleanupAllVbsScripts()
86 | {
87 | try
88 | {
89 | string[] commonPaths =
90 | [
91 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86),
92 | "Steam"),
93 | Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles),
94 | "Steam"),
95 | "C:\\GreenLuma"
96 | ];
97 |
98 | foreach (var basePath in commonPaths)
99 | try
100 | {
101 | var vbsPath = Path.Combine(basePath, "GLM_Autostart.vbs");
102 | if (File.Exists(vbsPath)) File.Delete(vbsPath);
103 | }
104 | catch
105 | {
106 | // ignored
107 | }
108 | }
109 | catch
110 | {
111 | // ignored
112 | }
113 | }
114 |
115 | private static void RemoveGreenLumaAutostart()
116 | {
117 | try
118 | {
119 | using var runKey = Registry.CurrentUser.OpenSubKey(RunKeyPath, true);
120 | runKey?.DeleteValue(GreenLumaValueName, false);
121 | }
122 | catch
123 | {
124 | // ignored
125 | }
126 | }
127 |
128 | private static void RemoveGreenLumaMonitor()
129 | {
130 | try
131 | {
132 | using var runKey = Registry.CurrentUser.OpenSubKey(RunKeyPath, true);
133 | runKey?.DeleteValue(GreenLumaMonitorValueName, false);
134 | }
135 | catch
136 | {
137 | // ignored
138 | }
139 | }
140 |
141 | private static void DeleteBackupKey()
142 | {
143 | try
144 | {
145 | Registry.CurrentUser.DeleteSubKeyTree(BackupKeyPath, false);
146 | }
147 | catch
148 | {
149 | // ignored
150 | }
151 | }
152 | }
--------------------------------------------------------------------------------
/App.xaml.cs:
--------------------------------------------------------------------------------
1 | using System.IO;
2 | using System.Windows;
3 | using GreenLuma_Manager.Models;
4 | using GreenLuma_Manager.Services;
5 |
6 | namespace GreenLuma_Manager;
7 |
8 | public partial class App
9 | {
10 | protected override void OnStartup(StartupEventArgs e)
11 | {
12 | base.OnStartup(e);
13 | try
14 | {
15 | PluginService.Initialize();
16 | PluginService.OnApplicationStartup();
17 |
18 | if (e.Args.Length > 0)
19 | foreach (var arg in e.Args)
20 | if (string.Equals(arg, "--launch-greenluma", StringComparison.OrdinalIgnoreCase))
21 | {
22 | try
23 | {
24 | var config = ConfigService.Load();
25 | GreenLumaService.LaunchGreenLumaAsync(config).GetAwaiter().GetResult();
26 | }
27 | catch
28 | {
29 | // ignored
30 | }
31 |
32 | Shutdown();
33 | return;
34 | }
35 |
36 | var profiles = ProfileService.LoadAll();
37 | var valid = new HashSet(profiles
38 | .SelectMany(p => p.Games)
39 | .Where(g => !string.IsNullOrWhiteSpace(g.AppId))
40 | .Select(g => g.AppId));
41 | IconCacheService.DeleteUnusedIcons(valid);
42 | _ = Task.Run(() => WarmupIconsAsync(profiles));
43 | }
44 | catch
45 | {
46 | // ignored
47 | }
48 | }
49 |
50 | protected override void OnExit(ExitEventArgs e)
51 | {
52 | try
53 | {
54 | PluginService.OnApplicationShutdown();
55 | }
56 | catch
57 | {
58 | // ignored
59 | }
60 |
61 | base.OnExit(e);
62 | }
63 |
64 | private static async Task WarmupIconsAsync(List profiles)
65 | {
66 | try
67 | {
68 | var semaphore = new SemaphoreSlim(6);
69 | var tasks = new List();
70 | foreach (var profile in profiles)
71 | {
72 | var changed = false;
73 | foreach (var game in profile.Games)
74 | {
75 | if (string.IsNullOrWhiteSpace(game.AppId))
76 | continue;
77 | var cached = IconCacheService.GetCachedIconPath(game.AppId);
78 | if (string.IsNullOrEmpty(cached))
79 | {
80 | await semaphore.WaitAsync();
81 | var t = Task.Run(async () =>
82 | {
83 | try
84 | {
85 | string? path = null;
86 | if (!string.IsNullOrWhiteSpace(game.IconUrl))
87 | path = await IconCacheService.DownloadAndCacheIconAsync(game.AppId,
88 | game.IconUrl);
89 |
90 | if (string.IsNullOrEmpty(path))
91 | {
92 | await SearchService.FetchIconUrlAsync(game);
93 | if (!string.IsNullOrWhiteSpace(game.IconUrl))
94 | path = await IconCacheService.DownloadAndCacheIconAsync(game.AppId,
95 | game.IconUrl);
96 | }
97 |
98 | if (!string.IsNullOrEmpty(path))
99 | {
100 | game.IconUrl = path;
101 | changed = true;
102 | }
103 | }
104 | catch
105 | {
106 | // ignored
107 | }
108 | finally
109 | {
110 | semaphore.Release();
111 | }
112 | });
113 | tasks.Add(t);
114 | }
115 | else if (!string.IsNullOrWhiteSpace(game.IconUrl) && !File.Exists(cached))
116 | {
117 | await semaphore.WaitAsync();
118 | var t2 = Task.Run(async () =>
119 | {
120 | try
121 | {
122 | var path =
123 | await IconCacheService.DownloadAndCacheIconAsync(game.AppId, game.IconUrl);
124 | if (!string.IsNullOrEmpty(path))
125 | {
126 | game.IconUrl = path;
127 | changed = true;
128 | }
129 | }
130 | catch
131 | {
132 | // ignored
133 | }
134 | finally
135 | {
136 | semaphore.Release();
137 | }
138 | });
139 | tasks.Add(t2);
140 | }
141 | }
142 |
143 | await Task.WhenAll(tasks);
144 | if (changed)
145 | try
146 | {
147 | ProfileService.Save(profile);
148 | }
149 | catch
150 | {
151 | // ignored
152 | }
153 |
154 | tasks.Clear();
155 | }
156 | }
157 | catch
158 | {
159 | // ignored
160 | }
161 | }
162 | }
--------------------------------------------------------------------------------
/Dialogs/CustomMessageBox.xaml.cs:
--------------------------------------------------------------------------------
1 | using System.Windows;
2 | using System.Windows.Controls;
3 | using System.Windows.Input;
4 | using System.Windows.Media;
5 | using SolidColorBrush = System.Windows.Media.SolidColorBrush;
6 |
7 | namespace GreenLuma_Manager.Dialogs;
8 |
9 | public partial class CustomMessageBox
10 | {
11 | private CustomMessageBox(string message, string title, MessageBoxButton buttons, MessageBoxImage icon)
12 | {
13 | InitializeComponent();
14 |
15 | TitleText.Text = title;
16 | MessageText.Text = message;
17 |
18 | SetIcon(icon);
19 | SetButtons(buttons);
20 |
21 | PreviewKeyDown += OnPreviewKeyDown;
22 | }
23 |
24 | public MessageBoxResult Result { get; private set; }
25 |
26 | private void OnPreviewKeyDown(object sender, KeyEventArgs e)
27 | {
28 | if (e.Key == Key.Escape)
29 | {
30 | Result = MessageBoxResult.Cancel;
31 | DialogResult = false;
32 | Close();
33 | }
34 | }
35 |
36 | private void TitleBar_MouseLeftButtonDown(object sender, MouseButtonEventArgs e)
37 | {
38 | if (e.LeftButton == MouseButtonState.Pressed) DragMove();
39 | }
40 |
41 | private void SetIcon(MessageBoxImage icon)
42 | {
43 | var iconData = GetIconData(icon);
44 |
45 | if (!string.IsNullOrEmpty(iconData.PathData))
46 | {
47 | IconPath.Data = Geometry.Parse(iconData.PathData);
48 | IconPath.Fill = iconData.Brush;
49 | }
50 | else
51 | {
52 | IconPath.Visibility = Visibility.Collapsed;
53 | }
54 | }
55 |
56 | private (string PathData, SolidColorBrush Brush) GetIconData(MessageBoxImage icon)
57 | {
58 | return icon switch
59 | {
60 | MessageBoxImage.Hand => (
61 | "M12,2C17.53,2 22,6.47 22,12C22,17.53 17.53,22 12,22C6.47,22 2,17.53 2,12C2,6.47 6.47,2 12,2M15.59,7L12,10.59L8.41,7L7,8.41L10.59,12L7,15.59L8.41,17L12,13.41L15.59,17L17,15.59L13.41,12L17,8.41L15.59,7Z",
62 | (SolidColorBrush)FindResource("Danger")
63 | ),
64 | MessageBoxImage.Question => (
65 | "M10,19H13V22H10V19M12,2C17.35,2.22 19.68,7.62 16.5,11.67C15.67,12.67 14.33,13.33 13.67,14.17C13,15 13,16 13,17H10C10,15.33 10,13.92 10.67,12.92C11.33,11.92 12.67,11.33 13.5,10.67C15.92,8.43 15.32,5.26 12,5A3,3 0 0,0 9,8H6A6,6 0 0,1 12,2Z",
66 | (SolidColorBrush)FindResource("Info")
67 | ),
68 | MessageBoxImage.Exclamation => (
69 | "M13,14H11V10H13M13,18H11V16H13M1,21H23L12,2L1,21Z",
70 | (SolidColorBrush)FindResource("Warning")
71 | ),
72 | MessageBoxImage.Asterisk => (
73 | "M11,9H13V7H11M12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M11,17H13V11H11V17Z",
74 | (SolidColorBrush)FindResource("Info")
75 | ),
76 | _ => (
77 | "M13,9H11V7H13M13,17H11V11H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z",
78 | (SolidColorBrush)FindResource("Info")
79 | )
80 | };
81 | }
82 |
83 | private void SetButtons(MessageBoxButton buttons)
84 | {
85 | ButtonPanel.Children.Clear();
86 |
87 | switch (buttons)
88 | {
89 | case MessageBoxButton.OK:
90 | AddButton("OK", MessageBoxResult.OK, true);
91 | break;
92 |
93 | case MessageBoxButton.OKCancel:
94 | AddButton("CANCEL", MessageBoxResult.Cancel, false);
95 | AddButton("OK", MessageBoxResult.OK, true);
96 | break;
97 |
98 | case MessageBoxButton.YesNoCancel:
99 | AddButton("CANCEL", MessageBoxResult.Cancel, false);
100 | AddButton("NO", MessageBoxResult.No, false);
101 | AddButton("YES", MessageBoxResult.Yes, true);
102 | break;
103 |
104 | case MessageBoxButton.YesNo:
105 | AddButton("NO", MessageBoxResult.No, false);
106 | AddButton("YES", MessageBoxResult.Yes, true);
107 | break;
108 | }
109 | }
110 |
111 | private void AddButton(string text, MessageBoxResult result, bool isPrimary)
112 | {
113 | var button = new Button
114 | {
115 | Content = text,
116 | Style = isPrimary
117 | ? (Style)FindResource("MessageBtn")
118 | : (Style)FindResource("SecondaryBtn"),
119 | Margin = ButtonPanel.Children.Count > 0
120 | ? new Thickness(8, 0, 0, 0)
121 | : new Thickness(0)
122 | };
123 |
124 | button.Click += (_, _) =>
125 | {
126 | Result = result;
127 | DialogResult = result != MessageBoxResult.Cancel && result != MessageBoxResult.No;
128 | Close();
129 | };
130 |
131 | if (isPrimary) button.IsDefault = true;
132 |
133 | ButtonPanel.Children.Add(button);
134 | }
135 |
136 | public static MessageBoxResult Show(
137 | string message,
138 | string title = "Message",
139 | MessageBoxButton buttons = MessageBoxButton.OK,
140 | MessageBoxImage icon = MessageBoxImage.None)
141 | {
142 | var messageBox = new CustomMessageBox(message, title, buttons, icon);
143 |
144 | SetOwnerWindow(messageBox);
145 |
146 | messageBox.ShowDialog();
147 | return messageBox.Result;
148 | }
149 |
150 | private static void SetOwnerWindow(CustomMessageBox messageBox)
151 | {
152 | var activeWindow = Application.Current.Windows
153 | .OfType()
154 | .FirstOrDefault(w => w.IsActive);
155 |
156 | if (activeWindow != null && activeWindow != messageBox)
157 | messageBox.Owner = activeWindow;
158 | else if (Application.Current.MainWindow != null && Application.Current.MainWindow != messageBox)
159 | messageBox.Owner = Application.Current.MainWindow;
160 | }
161 | }
--------------------------------------------------------------------------------
/Services/ProfileService.cs:
--------------------------------------------------------------------------------
1 | using System.IO;
2 | using System.Text;
3 | using System.Text.Json;
4 | using GreenLuma_Manager.Models;
5 | using Newtonsoft.Json.Linq;
6 |
7 | namespace GreenLuma_Manager.Services;
8 |
9 | public class ProfileService
10 | {
11 | private static readonly string ProfilesDir = Path.Combine(
12 | Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
13 | "GLM_Manager",
14 | "profiles");
15 |
16 | public static List LoadAll()
17 | {
18 | var profiles = new List();
19 | try
20 | {
21 | EnsureProfilesDirectoryExists();
22 | TryMigrateProfilesFromOldVersion();
23 | LoadProfilesFromDirectory(profiles);
24 | if (profiles.Count == 0) return CreateDefaultProfile(profiles);
25 | }
26 | catch
27 | {
28 | // ignored
29 | }
30 |
31 | return profiles;
32 | }
33 |
34 | private static void EnsureProfilesDirectoryExists()
35 | {
36 | if (!Directory.Exists(ProfilesDir)) Directory.CreateDirectory(ProfilesDir);
37 | }
38 |
39 | private static List CreateDefaultProfile(List profiles)
40 | {
41 | var defaultProfile = new Profile { Name = "default" };
42 | Save(defaultProfile);
43 | profiles.Add(defaultProfile);
44 | return profiles;
45 | }
46 |
47 | private static void LoadProfilesFromDirectory(List profiles)
48 | {
49 | foreach (var file in Directory.GetFiles(ProfilesDir, "*.json"))
50 | try
51 | {
52 | var profile = DeserializeProfile(File.ReadAllText(file, Encoding.UTF8));
53 | if (profile != null) profiles.Add(profile);
54 | }
55 | catch
56 | {
57 | // ignored
58 | }
59 | }
60 |
61 | public static Profile? Load(string profileName)
62 | {
63 | try
64 | {
65 | var filePath = GetProfileFilePath(profileName);
66 | if (!File.Exists(filePath))
67 | return null;
68 |
69 | var json = File.ReadAllText(filePath, Encoding.UTF8);
70 | return DeserializeProfile(json);
71 | }
72 | catch
73 | {
74 | return null;
75 | }
76 | }
77 |
78 | public static void Save(Profile profile)
79 | {
80 | try
81 | {
82 | EnsureProfilesDirectoryExists();
83 | var filePath = GetProfileFilePath(profile.Name);
84 | var json = SerializeProfile(profile);
85 | File.WriteAllText(filePath, json, Encoding.UTF8);
86 | }
87 | catch
88 | {
89 | // ignored
90 | }
91 | }
92 |
93 | public static void Delete(string profileName)
94 | {
95 | try
96 | {
97 | if (string.Equals(profileName, "default", StringComparison.OrdinalIgnoreCase))
98 | return;
99 |
100 | var filePath = GetProfileFilePath(profileName);
101 | if (File.Exists(filePath)) File.Delete(filePath);
102 | }
103 | catch
104 | {
105 | // ignored
106 | }
107 | }
108 |
109 | public static void Export(Profile profile, string destinationPath)
110 | {
111 | try
112 | {
113 | var json = SerializeProfile(profile);
114 | File.WriteAllText(destinationPath, json, Encoding.UTF8);
115 | }
116 | catch
117 | {
118 | // ignored
119 | }
120 | }
121 |
122 | public static Profile? Import(string sourcePath)
123 | {
124 | try
125 | {
126 | var json = File.ReadAllText(sourcePath, Encoding.UTF8);
127 | return DeserializeProfile(json);
128 | }
129 | catch
130 | {
131 | return null;
132 | }
133 | }
134 |
135 | private static string GetProfileFilePath(string profileName)
136 | {
137 | var sanitizedName = SanitizeFileName(profileName);
138 | return Path.Combine(ProfilesDir, $"{sanitizedName}.json");
139 | }
140 |
141 | private static Profile? DeserializeProfile(string json)
142 | {
143 | try
144 | {
145 | return JsonSerializer.Deserialize(json);
146 | }
147 | catch
148 | {
149 | return null;
150 | }
151 | }
152 |
153 | private static string SerializeProfile(Profile profile)
154 | {
155 | return JsonSerializer.Serialize(profile);
156 | }
157 |
158 | private static string SanitizeFileName(string name)
159 | {
160 | var invalidChars = Path.GetInvalidFileNameChars();
161 | return new string([.. name.Select(c => invalidChars.Contains(c) ? '_' : c)]);
162 | }
163 |
164 | private static void TryMigrateProfilesFromOldVersion()
165 | {
166 | try
167 | {
168 | if (!Directory.Exists(ProfilesDir)) return;
169 |
170 | var filesToMigrate = Directory.GetFiles(ProfilesDir, "*.json");
171 |
172 | foreach (var file in filesToMigrate)
173 | try
174 | {
175 | var rc3Json = File.ReadAllText(file, Encoding.UTF8);
176 | var rc3Data = JObject.Parse(rc3Json);
177 |
178 | if (rc3Data["games"] is not JArray gamesArray || gamesArray.Count == 0) continue;
179 |
180 | if (gamesArray[0] is not JObject firstGame || firstGame["id"] == null) continue;
181 |
182 | var profile = new Profile
183 | {
184 | Name = rc3Data["name"]?.ToString() ?? "default",
185 | Games =
186 | [
187 | .. gamesArray
188 | .Select(gameToken => new Game
189 | {
190 | AppId = gameToken["id"]?.ToString() ?? string.Empty,
191 | Name = gameToken["name"]?.ToString() ?? string.Empty,
192 | Type = gameToken["type"]?.ToString() ?? "Game"
193 | })
194 | .Where(g => !string.IsNullOrEmpty(g.AppId))
195 | ]
196 | };
197 |
198 | Save(profile);
199 | }
200 | catch
201 | {
202 | // ignored
203 | }
204 | }
205 | catch
206 | {
207 | // ignored
208 | }
209 | }
210 | }
--------------------------------------------------------------------------------
/Dialogs/CustomMessageBox.xaml:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
62 |
63 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
105 |
106 |
107 |
113 |
119 |
120 |
121 |
122 |
123 |
130 |
131 |
137 |
141 |
142 |
143 |
--------------------------------------------------------------------------------
/Services/UpdateService.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics;
2 | using System.IO;
3 | using System.Net.Http;
4 | using System.Text.RegularExpressions;
5 | using GreenLuma_Manager.Models;
6 |
7 | namespace GreenLuma_Manager.Services;
8 |
9 | public partial class UpdateService
10 | {
11 | private const string GitHubApiUrl = "https://api.github.com/repos/3vil3vo/GreenLuma-Manager/releases/latest";
12 | private static readonly string[] RcSeparator = ["-rc"];
13 |
14 | private static readonly HttpClient Client;
15 |
16 | static UpdateService()
17 | {
18 | Client = new HttpClient();
19 | Client.DefaultRequestHeaders.Add("User-Agent", "GreenLuma-Manager");
20 | }
21 |
22 | private static string CurrentVersion => MainWindow.Version;
23 |
24 | [GeneratedRegex("\"tag_name\"\\s*:\\s*\"([^\"]+)\"")]
25 | private static partial Regex TagNameRegex();
26 |
27 | [GeneratedRegex("\"browser_download_url\"\\s*:\\s*\"([^\"]+)\"")]
28 | private static partial Regex BrowserDownloadUrlRegex();
29 |
30 | [GeneratedRegex("\"body\"\\s*:\\s*\"([^\"]*)\"")]
31 | private static partial Regex BodyRegex();
32 |
33 | public static async Task CheckForUpdatesAsync()
34 | {
35 | try
36 | {
37 | var response = await Client.GetStringAsync(GitHubApiUrl);
38 | return ParseUpdateInfo(response);
39 | }
40 | catch
41 | {
42 | return null;
43 | }
44 | }
45 |
46 | private static UpdateInfo? ParseUpdateInfo(string jsonResponse)
47 | {
48 | var tagMatch = TagNameRegex().Match(jsonResponse);
49 | var downloadMatch = BrowserDownloadUrlRegex().Match(jsonResponse);
50 | var bodyMatch = BodyRegex().Match(jsonResponse);
51 |
52 | if (!tagMatch.Success)
53 | return null;
54 |
55 | var latestTag = tagMatch.Groups[1].Value;
56 | var downloadUrl = downloadMatch.Success ? downloadMatch.Groups[1].Value : string.Empty;
57 | var releaseNotes = bodyMatch.Success
58 | ? bodyMatch.Groups[1].Value.Replace("\\n", "\n").Replace("\\r", "\r")
59 | : string.Empty;
60 |
61 | var currentNormalized = NormalizeVersion(CurrentVersion);
62 | var latestNormalized = NormalizeVersion(latestTag);
63 |
64 | return new UpdateInfo
65 | {
66 | CurrentVersion = CurrentVersion,
67 | LatestVersion = FormatDisplayVersion(latestTag),
68 | LatestVersionTag = latestTag,
69 | UpdateAvailable =
70 | string.Compare(latestNormalized, currentNormalized, StringComparison.OrdinalIgnoreCase) > 0,
71 | DownloadUrl = downloadUrl,
72 | ReleaseNotes = releaseNotes
73 | };
74 | }
75 |
76 | private static string NormalizeVersion(string version)
77 | {
78 | var cleaned = version.ToLower().Trim().Replace("v", "");
79 |
80 | if (string.IsNullOrEmpty(cleaned))
81 | return "00000.00000.00000.0000.0000";
82 |
83 | if (cleaned.StartsWith("rc") && !cleaned.Contains(".0.0"))
84 | {
85 | var rcPart = cleaned[2..];
86 | var rcParts = rcPart.Split('.');
87 |
88 | var rcMajor = ParseVersionPart(rcParts, 0);
89 | var rcMinor = rcParts.Length > 1 ? ParseVersionPart(rcParts, 1) : 0;
90 |
91 | return $"00001.00000.00000.{rcMajor:D4}.{rcMinor:D4}";
92 | }
93 |
94 | var parts = cleaned.Split('-', 2);
95 | var baseVersion = parts[0];
96 |
97 | var versionParts = baseVersion.Split('.');
98 | var major = ParseVersionPart(versionParts, 0);
99 | var minor = ParseVersionPart(versionParts, 1);
100 | var patch = ParseVersionPart(versionParts, 2);
101 |
102 | if (parts.Length == 1) return $"{major:D5}.{minor:D5}.{patch:D5}.9999.9999";
103 |
104 | var prerelease = parts[1];
105 |
106 | if (prerelease.StartsWith("rc"))
107 | {
108 | var rcPart = prerelease[2..];
109 | var rcParts = rcPart.Split('.');
110 |
111 | var rcMajor = ParseVersionPart(rcParts, 0);
112 | var rcMinor = rcParts.Length > 1 ? ParseVersionPart(rcParts, 1) : 0;
113 |
114 | return $"{major:D5}.{minor:D5}.{patch:D5}.{rcMajor:D4}.{rcMinor:D4}";
115 | }
116 |
117 | return $"{major:D5}.{minor:D5}.{patch:D5}.0000.0000";
118 | }
119 |
120 | private static int ParseVersionPart(string[] parts, int index)
121 | {
122 | if (index >= parts.Length)
123 | return 0;
124 |
125 | if (int.TryParse(parts[index], out var result))
126 | return result;
127 |
128 | return 0;
129 | }
130 |
131 | private static string FormatDisplayVersion(string version)
132 | {
133 | var cleaned = version.ToLower().Replace("v", "");
134 |
135 | if (cleaned.Contains("-rc"))
136 | {
137 | var parts = cleaned.Split(RcSeparator, StringSplitOptions.None);
138 | if (parts.Length > 1) return "RC" + parts[1].ToUpper();
139 | }
140 |
141 | if (cleaned.StartsWith("rc")) return cleaned.ToUpper();
142 |
143 | return cleaned;
144 | }
145 |
146 | public static async Task PerformAutoUpdateAsync(string downloadUrl)
147 | {
148 | try
149 | {
150 | var currentExePath = Environment.ProcessPath!;
151 | var tempExePath = await DownloadUpdate(downloadUrl);
152 |
153 | CreateAndExecuteUpdateScript(tempExePath, currentExePath);
154 |
155 | return true;
156 | }
157 | catch
158 | {
159 | return false;
160 | }
161 | }
162 |
163 | private static async Task DownloadUpdate(string downloadUrl)
164 | {
165 | var tempDir = Path.GetTempPath();
166 | var tempExePath = Path.Combine(tempDir, "GreenLumaManager_Update.exe");
167 |
168 | using var response = await Client.GetAsync(downloadUrl);
169 | response.EnsureSuccessStatusCode();
170 |
171 | await using var fileStream = new FileStream(tempExePath, FileMode.Create, FileAccess.Write, FileShare.None);
172 | await response.Content.CopyToAsync(fileStream);
173 |
174 | return tempExePath;
175 | }
176 |
177 | private static void CreateAndExecuteUpdateScript(string tempExePath, string currentExePath)
178 | {
179 | var scriptPath = Path.Combine(Path.GetTempPath(), "update.bat");
180 | var scriptContent = GenerateUpdateScript(tempExePath, currentExePath);
181 |
182 | File.WriteAllText(scriptPath, scriptContent);
183 |
184 | Process.Start(new ProcessStartInfo
185 | {
186 | FileName = scriptPath,
187 | CreateNoWindow = true,
188 | UseShellExecute = false,
189 | WindowStyle = ProcessWindowStyle.Hidden
190 | });
191 | }
192 |
193 | private static string GenerateUpdateScript(string tempExePath, string currentExePath)
194 | {
195 | var currentExeName = Path.GetFileName(currentExePath);
196 |
197 | return $@"@echo off
198 | echo Waiting for application to close...
199 | timeout /t 2 /nobreak >nul
200 |
201 | taskkill /F /IM ""{currentExeName}"" >nul 2>&1
202 |
203 | echo Waiting for process to fully terminate...
204 | :wait_process
205 | tasklist /FI ""IMAGENAME eq {currentExeName}"" 2>NUL | find /I /N ""{currentExeName}"">NUL
206 | if ""%ERRORLEVEL%""==""0"" (
207 | timeout /t 1 /nobreak >nul
208 | goto wait_process
209 | )
210 |
211 | echo Process terminated.
212 | echo Updating...
213 |
214 | del ""{currentExePath}"" >nul 2>&1
215 | :wait_delete
216 | if exist ""{currentExePath}"" (
217 | timeout /t 1 /nobreak >nul
218 | del ""{currentExePath}"" >nul 2>&1
219 | goto wait_delete
220 | )
221 |
222 | move /y ""{tempExePath}"" ""{currentExePath}""
223 | if errorlevel 1 (
224 | echo Update failed!
225 | pause
226 | exit /b 1
227 | )
228 |
229 | echo.
230 | echo ========================================
231 | echo Update Complete!
232 | echo ========================================
233 | echo.
234 | echo Restarting GreenLuma Manager...
235 | timeout /t 2 /nobreak >nul
236 |
237 | start """" ""{currentExePath}""
238 | exit
239 | ";
240 | }
241 | }
--------------------------------------------------------------------------------
/Services/SteamService.cs:
--------------------------------------------------------------------------------
1 | using SteamKit2;
2 |
3 | namespace GreenLuma_Manager.Services;
4 |
5 | public sealed class SteamService : IDisposable
6 | {
7 | private static readonly Lazy InstanceHolder = new(() => new SteamService());
8 |
9 | private readonly Task _callbackLoop;
10 | private readonly CallbackManager _callbackManager;
11 | private readonly TaskCompletionSource _connectedTcs;
12 | private readonly CancellationTokenSource _cts;
13 | private readonly TaskCompletionSource _loggedOnTcs;
14 | private readonly SteamApps _steamApps;
15 | private readonly SteamClient _steamClient;
16 | private readonly SteamUser _steamUser;
17 |
18 | private bool _isConnected;
19 | private bool _isLoggedOn;
20 | private bool _isRunning;
21 |
22 | private SteamService()
23 | {
24 | _steamClient = new SteamClient();
25 | _callbackManager = new CallbackManager(_steamClient);
26 | _steamUser = _steamClient.GetHandler()!;
27 | _steamApps = _steamClient.GetHandler()!;
28 |
29 | _cts = new CancellationTokenSource();
30 | _connectedTcs = new TaskCompletionSource();
31 | _loggedOnTcs = new TaskCompletionSource();
32 |
33 | _callbackManager.Subscribe(OnConnected);
34 | _callbackManager.Subscribe(OnDisconnected);
35 | _callbackManager.Subscribe(OnLoggedOn);
36 |
37 | _isRunning = true;
38 | _callbackLoop = Task.Run(CallbackLoop);
39 |
40 | _steamClient.Connect();
41 | }
42 |
43 | public static SteamService Instance => InstanceHolder.Value;
44 |
45 | public void Dispose()
46 | {
47 | _isRunning = false;
48 | _cts.Cancel();
49 | _steamClient.Disconnect();
50 | try
51 | {
52 | _callbackLoop.Wait(1000);
53 | }
54 | catch
55 | {
56 | // ignored
57 | }
58 |
59 | _cts.Dispose();
60 | }
61 |
62 | public async Task GetGameDetailsAsync(uint appId)
63 | {
64 | var result = await GetAppInfoBatchAsync([appId]).ConfigureAwait(false);
65 | return result.GetValueOrDefault(appId);
66 | }
67 |
68 | public async Task> GetAppInfoBatchAsync(List appIds)
69 | {
70 | var results = new Dictionary();
71 | var maxRetries = 2;
72 |
73 | for (var attempt = 0; attempt <= maxRetries; attempt++)
74 | try
75 | {
76 | await EnsureReadyAsync().ConfigureAwait(false);
77 |
78 | var requests = appIds.Select(id => new SteamApps.PICSRequest { ID = id, AccessToken = 0 }).ToList();
79 | using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
80 |
81 | var job = _steamApps.PICSGetProductInfo(requests, []);
82 | var task = job.ToTask();
83 |
84 | if (await Task.WhenAny(task, Task.Delay(5000, cts.Token)) != task)
85 | {
86 | if (attempt < maxRetries) continue;
87 | break;
88 | }
89 |
90 | var result = await task.ConfigureAwait(false);
91 |
92 | if (result.Failed || result.Results == null)
93 | {
94 | if (attempt < maxRetries)
95 | {
96 | await Task.Delay(500, cts.Token).ConfigureAwait(false);
97 | continue;
98 | }
99 |
100 | return results;
101 | }
102 |
103 | foreach (var callback in result.Results)
104 | foreach (var (appId, appData) in callback.Apps)
105 | {
106 | var kv = appData.KeyValues;
107 | var common = kv["common"];
108 |
109 | var name = common["name"].Value ?? $"App {appId}";
110 | var type = MapSteamTypeToDisplayType(common["type"].Value ?? "Game");
111 |
112 | var clientIconHash = common["clienticon"].Value;
113 | var parentId = common["parent"].Value;
114 |
115 | var libAssets = common["library_assets"];
116 | var heroHash = libAssets["hero_capsule"]["image"].Value;
117 |
118 | var assets = common["assets"];
119 | var mainHash = assets["main_capsule"]["image"].Value;
120 |
121 | var headerNode = common["header_image"];
122 | var headerImage = headerNode.Value;
123 | if (string.IsNullOrEmpty(headerImage))
124 | headerImage = headerNode["english"].Value;
125 |
126 | results[appId] = new GameDetails(
127 | appId.ToString(),
128 | type,
129 | name,
130 | clientIconHash,
131 | heroHash,
132 | mainHash,
133 | parentId,
134 | headerImage
135 | );
136 | }
137 |
138 | if (results.Count > 0) return results;
139 | }
140 | catch
141 | {
142 | if (attempt == maxRetries) break;
143 | await Task.Delay(500).ConfigureAwait(false);
144 | }
145 |
146 | return results;
147 | }
148 |
149 | public async Task GetAppPackageInfoAsync(uint appId)
150 | {
151 | try
152 | {
153 | await EnsureReadyAsync().ConfigureAwait(false);
154 |
155 | var request = new SteamApps.PICSRequest { ID = appId, AccessToken = 0 };
156 | var job = _steamApps.PICSGetProductInfo([request], []);
157 |
158 | var result = await job.ToTask().ConfigureAwait(false);
159 |
160 | if (result.Failed || result.Results == null)
161 | return null;
162 |
163 | foreach (var callback in result.Results)
164 | if (callback.Apps.TryGetValue(appId, out var appData))
165 | return ParseAppPackageInfo(appId, appData);
166 |
167 | return null;
168 | }
169 | catch
170 | {
171 | return null;
172 | }
173 | }
174 |
175 | private static AppPackageInfo? ParseAppPackageInfo(uint appId,
176 | SteamApps.PICSProductInfoCallback.PICSProductInfo appData)
177 | {
178 | var kv = appData.KeyValues;
179 |
180 | var type = kv["common"]["type"].Value;
181 | if (string.Equals(type, "depot", StringComparison.OrdinalIgnoreCase))
182 | return null;
183 |
184 | var info = new AppPackageInfo
185 | {
186 | AppId = appId.ToString()
187 | };
188 |
189 | var dlcList = kv["common"]["extended"]["listofdlc"].Value;
190 | if (!string.IsNullOrEmpty(dlcList))
191 | info.DlcAppIds = dlcList.Split(',', StringSplitOptions.RemoveEmptyEntries).ToList();
192 |
193 | foreach (var dlcId in info.DlcAppIds)
194 | info.DlcDepots[dlcId] = [];
195 |
196 | var depotsNode = kv["depots"];
197 | foreach (var child in depotsNode.Children)
198 | {
199 | if (!uint.TryParse(child.Name, out var depotId))
200 | continue;
201 |
202 | if (depotId == appId)
203 | continue;
204 |
205 | if (child["manifests"] == KeyValue.Invalid && child["depotfromapp"] == KeyValue.Invalid)
206 | continue;
207 |
208 | var dlcAppId = child["dlcappid"].Value;
209 |
210 | if (!string.IsNullOrEmpty(dlcAppId) && info.DlcDepots.TryGetValue(dlcAppId, out var dlcDepotList))
211 | dlcDepotList.Add(depotId.ToString());
212 | else
213 | info.Depots.Add(depotId.ToString());
214 | }
215 |
216 | return info;
217 | }
218 |
219 | private async Task EnsureReadyAsync()
220 | {
221 | if (!_isConnected)
222 | await _connectedTcs.Task.ConfigureAwait(false);
223 |
224 | if (!_isLoggedOn)
225 | await _loggedOnTcs.Task.ConfigureAwait(false);
226 | }
227 |
228 | private async Task CallbackLoop()
229 | {
230 | while (_isRunning && !_cts.Token.IsCancellationRequested)
231 | {
232 | _callbackManager.RunWaitCallbacks(TimeSpan.FromMilliseconds(100));
233 | await Task.Delay(100).ConfigureAwait(false);
234 | }
235 | }
236 |
237 | private void OnConnected(SteamClient.ConnectedCallback callback)
238 | {
239 | _isConnected = true;
240 | _connectedTcs.TrySetResult();
241 | _steamUser.LogOnAnonymous();
242 | }
243 |
244 | private void OnDisconnected(SteamClient.DisconnectedCallback callback)
245 | {
246 | _isConnected = false;
247 | _isLoggedOn = false;
248 |
249 | Task.Delay(TimeSpan.FromSeconds(5)).ContinueWith(_ =>
250 | {
251 | if (_isRunning) _steamClient.Connect();
252 | });
253 | }
254 |
255 | private void OnLoggedOn(SteamUser.LoggedOnCallback callback)
256 | {
257 | if (callback.Result == EResult.OK)
258 | {
259 | _isLoggedOn = true;
260 | _loggedOnTcs.TrySetResult();
261 | }
262 | }
263 |
264 | private static string MapSteamTypeToDisplayType(string steamType)
265 | {
266 | return steamType.ToLower() switch
267 | {
268 | "game" => "Game",
269 | "dlc" => "DLC",
270 | "demo" => "Demo",
271 | "mod" => "Mod",
272 | "video" => "Video",
273 | "music" => "Soundtrack",
274 | "bundle" => "Bundle",
275 | "episode" => "Episode",
276 | "tool" or "advertising" => "Software",
277 | _ => "Game"
278 | };
279 | }
280 | }
281 |
282 | public record GameDetails(
283 | string AppId,
284 | string Type,
285 | string Name,
286 | string? ClientIconHash = null,
287 | string? HeroHash = null,
288 | string? MainHash = null,
289 | string? ParentAppId = null,
290 | string? HeaderImage = null
291 | );
--------------------------------------------------------------------------------
/Dialogs/SettingsDialog.xaml.cs:
--------------------------------------------------------------------------------
1 | using System.IO;
2 | using System.Windows;
3 | using System.Windows.Input;
4 | using GreenLuma_Manager.Models;
5 | using GreenLuma_Manager.Services;
6 | using GreenLuma_Manager.Utilities;
7 | using Microsoft.Win32;
8 |
9 | namespace GreenLuma_Manager.Dialogs;
10 |
11 | public partial class SettingsDialog
12 | {
13 | private readonly Config _config;
14 |
15 | public SettingsDialog(Config config)
16 | {
17 | InitializeComponent();
18 | _config = config;
19 |
20 | LoadSettings();
21 | UpdateAutoUpdateVisibility();
22 |
23 | PreviewKeyDown += OnPreviewKeyDown;
24 | }
25 |
26 | private void LoadSettings()
27 | {
28 | TxtSteamPath.Text = _config.SteamPath;
29 | TxtGreenLumaPath.Text = _config.GreenLumaPath;
30 | ChkReplaceSteamAutostart.IsChecked = _config.ReplaceSteamAutostart;
31 | ChkDisableUpdateCheck.IsChecked = _config.DisableUpdateCheck;
32 | ChkAutoUpdate.IsChecked = _config.AutoUpdate;
33 | }
34 |
35 | private void OnPreviewKeyDown(object sender, KeyEventArgs e)
36 | {
37 | if (e.Key == Key.Escape) Cancel_Click(this, new RoutedEventArgs());
38 | }
39 |
40 | private void Nav_Checked(object sender, RoutedEventArgs e)
41 | {
42 | if (ViewGeneral == null || ViewSystem == null || ViewAdvanced == null) return;
43 |
44 | ViewGeneral.Visibility = Visibility.Collapsed;
45 | ViewSystem.Visibility = Visibility.Collapsed;
46 | ViewAdvanced.Visibility = Visibility.Collapsed;
47 |
48 | if (NavGeneral.IsChecked == true) ShowView(ViewGeneral);
49 | else if (NavSystem.IsChecked == true) ShowView(ViewSystem);
50 | else if (NavAdvanced.IsChecked == true) ShowView(ViewAdvanced);
51 | }
52 |
53 | private void ShowView(UIElement view)
54 | {
55 | view.Visibility = Visibility.Visible;
56 | }
57 |
58 | private void BrowseSteam_Click(object sender, RoutedEventArgs e)
59 | {
60 | var dialog = new OpenFolderDialog
61 | {
62 | Title = "Select Steam folder"
63 | };
64 |
65 | if (!string.IsNullOrWhiteSpace(TxtSteamPath.Text) && Directory.Exists(TxtSteamPath.Text))
66 | dialog.InitialDirectory = TxtSteamPath.Text;
67 |
68 | if (dialog.ShowDialog() == true) TxtSteamPath.Text = dialog.FolderName;
69 | }
70 |
71 | private void BrowseGreenLuma_Click(object sender, RoutedEventArgs e)
72 | {
73 | var dialog = new OpenFolderDialog
74 | {
75 | Title = "Select GreenLuma folder"
76 | };
77 |
78 | if (!string.IsNullOrWhiteSpace(TxtGreenLumaPath.Text) && Directory.Exists(TxtGreenLumaPath.Text))
79 | dialog.InitialDirectory = TxtGreenLumaPath.Text;
80 |
81 | if (dialog.ShowDialog() == true) TxtGreenLumaPath.Text = dialog.FolderName;
82 | }
83 |
84 | private void AutoDetect_Click(object sender, RoutedEventArgs e)
85 | {
86 | var (steamPath, greenLumaPath) = PathDetector.DetectPaths();
87 |
88 | TxtSteamPath.Text = steamPath;
89 | TxtGreenLumaPath.Text = greenLumaPath;
90 |
91 | if (!string.IsNullOrWhiteSpace(steamPath) && !string.IsNullOrWhiteSpace(greenLumaPath))
92 | CustomMessageBox.Show("Paths detected successfully!", "Success", icon: MessageBoxImage.Asterisk);
93 | else
94 | CustomMessageBox.Show("Could not detect all paths automatically.", "Detection",
95 | icon: MessageBoxImage.Exclamation);
96 | }
97 |
98 | private void DisableUpdateCheck_Changed(object sender, RoutedEventArgs e)
99 | {
100 | UpdateAutoUpdateVisibility();
101 | }
102 |
103 | private void UpdateAutoUpdateVisibility()
104 | {
105 | if (ChkAutoUpdate == null || ChkDisableUpdateCheck == null)
106 | return;
107 |
108 | var isEnabled = !ChkDisableUpdateCheck.IsChecked.GetValueOrDefault();
109 | ChkAutoUpdate.IsEnabled = isEnabled;
110 |
111 | if (!isEnabled) ChkAutoUpdate.IsChecked = false;
112 | }
113 |
114 | private void WipeData_Click(object sender, RoutedEventArgs e)
115 | {
116 | if (CustomMessageBox.Show("This will delete all profiles and settings. Continue?", "Wipe Data",
117 | MessageBoxButton.YesNo, MessageBoxImage.Exclamation) != MessageBoxResult.Yes)
118 | return;
119 |
120 | if (CustomMessageBox.Show("Are you absolutely sure? This cannot be undone.", "Confirm Wipe",
121 | MessageBoxButton.YesNo, MessageBoxImage.Exclamation) != MessageBoxResult.Yes)
122 | return;
123 |
124 | ConfigService.WipeData();
125 | CustomMessageBox.Show("All data has been wiped. The application will now close.", "Complete",
126 | icon: MessageBoxImage.Asterisk);
127 | Application.Current.Shutdown();
128 | }
129 |
130 | private void Ok_Click(object sender, RoutedEventArgs e)
131 | {
132 | var steamPath = NormalizePath(TxtSteamPath.Text);
133 | var greenLumaPath = NormalizePath(TxtGreenLumaPath.Text);
134 |
135 | if (!ValidatePaths(steamPath, greenLumaPath)) return;
136 |
137 | _config.SteamPath = steamPath;
138 | _config.GreenLumaPath = greenLumaPath;
139 | _config.ReplaceSteamAutostart = ChkReplaceSteamAutostart.IsChecked.GetValueOrDefault();
140 | _config.DisableUpdateCheck = ChkDisableUpdateCheck.IsChecked.GetValueOrDefault();
141 | _config.AutoUpdate = ChkAutoUpdate.IsChecked.GetValueOrDefault();
142 |
143 | ConfigService.Save(_config);
144 | AutostartManager.ManageAutostart(_config.ReplaceSteamAutostart, _config);
145 |
146 | DialogResult = true;
147 | Close();
148 | }
149 |
150 | private void Cancel_Click(object sender, RoutedEventArgs e)
151 | {
152 | DialogResult = false;
153 | Close();
154 | }
155 |
156 | private static string NormalizePath(string? path)
157 | {
158 | return string.IsNullOrWhiteSpace(path) ? string.Empty : path.Trim().TrimEnd('\\', '/');
159 | }
160 |
161 | private static bool ValidatePaths(string steamPath, string greenLumaPath)
162 | {
163 | if (string.IsNullOrWhiteSpace(steamPath))
164 | {
165 | CustomMessageBox.Show("Steam path cannot be empty.", "Validation", icon: MessageBoxImage.Exclamation);
166 | return false;
167 | }
168 |
169 | if (string.IsNullOrWhiteSpace(greenLumaPath))
170 | {
171 | CustomMessageBox.Show("GreenLuma path cannot be empty.", "Validation", icon: MessageBoxImage.Exclamation);
172 | return false;
173 | }
174 |
175 | if (!Directory.Exists(steamPath))
176 | {
177 | CustomMessageBox.Show("Steam path does not exist.", "Validation", icon: MessageBoxImage.Exclamation);
178 | return false;
179 | }
180 |
181 | var steamExePath = Path.Combine(steamPath, "Steam.exe");
182 | if (!File.Exists(steamExePath))
183 | {
184 | CustomMessageBox.Show($"Steam.exe not found at:\n{steamExePath}", "Validation",
185 | icon: MessageBoxImage.Exclamation);
186 | return false;
187 | }
188 |
189 | if (!Directory.Exists(greenLumaPath))
190 | {
191 | CustomMessageBox.Show($"GreenLuma path does not exist:\n{greenLumaPath}", "Validation",
192 | icon: MessageBoxImage.Exclamation);
193 | return false;
194 | }
195 |
196 | if (string.Equals(Path.GetFullPath(steamPath), Path.GetFullPath(greenLumaPath),
197 | StringComparison.OrdinalIgnoreCase))
198 | {
199 | var result = CustomMessageBox.Show(
200 | "Installing GreenLuma in the Steam directory is not recommended. Some games scan this location for GreenLuma files, which may result in detection.\n\n" +
201 | "Do you want to continue anyway?",
202 | "Security Warning",
203 | MessageBoxButton.YesNo,
204 | MessageBoxImage.Warning);
205 |
206 | if (result != MessageBoxResult.Yes)
207 | return false;
208 | }
209 |
210 | if (IsPathReadOnly(greenLumaPath))
211 | {
212 | CustomMessageBox.Show(
213 | $"The GreenLuma path is read-only.\nPlease ensure the folder is writable and not marked as Read-Only.\nPath: {greenLumaPath}",
214 | "Validation",
215 | icon: MessageBoxImage.Exclamation);
216 | return false;
217 | }
218 |
219 | var missingFiles = GetMissingGreenLumaFiles(greenLumaPath);
220 | if (missingFiles.Count > 0)
221 | {
222 | CustomMessageBox.Show(
223 | $"GreenLuma installation is incomplete.\nThe following files are missing:\n\n{string.Join("\n", missingFiles)}",
224 | "Validation",
225 | icon: MessageBoxImage.Exclamation);
226 | return false;
227 | }
228 |
229 | return true;
230 | }
231 |
232 | private static bool IsPathReadOnly(string path)
233 | {
234 | try
235 | {
236 | var info = new DirectoryInfo(path);
237 | if ((info.Attributes & FileAttributes.ReadOnly) != 0)
238 | return true;
239 |
240 | var tempFile = Path.Combine(path, Path.GetRandomFileName());
241 | using (File.Create(tempFile, 1, FileOptions.DeleteOnClose))
242 | {
243 | }
244 |
245 | return false;
246 | }
247 | catch
248 | {
249 | return true;
250 | }
251 | }
252 |
253 | private static List GetMissingGreenLumaFiles(string path)
254 | {
255 | string[] requiredFiles =
256 | [
257 | "DLLInjector.exe",
258 | "DLLInjector.ini",
259 | "GreenLumaSettings_2025.exe",
260 | "GreenLuma_2025_x64.dll",
261 | "GreenLuma_2025_x86.dll",
262 | Path.Combine("GreenLuma2025_Files", "AchievementUnlocked.wav"),
263 | Path.Combine("GreenLuma2025_Files", "BootImage.bmp")
264 | ];
265 |
266 | var missing = new List();
267 | foreach (var file in requiredFiles)
268 | if (!File.Exists(Path.Combine(path, file)))
269 | missing.Add(file);
270 |
271 | var x86Launcher = Path.Combine(path, "bin", "x86launcher.exe");
272 | var x64Launcher = Path.Combine(path, "bin", "x64launcher.exe");
273 |
274 | if (!File.Exists(x86Launcher) && !File.Exists(x64Launcher))
275 | missing.Add(Path.Combine("bin", "x86launcher.exe"));
276 |
277 | return missing;
278 | }
279 | }
--------------------------------------------------------------------------------
/Dialogs/CreateProfileDialog.xaml:
--------------------------------------------------------------------------------
1 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
30 |
65 |
92 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 |
129 |
135 |
136 |
142 |
171 |
172 |
173 |
174 |
175 |
180 |
181 |
187 |
188 |
193 |
199 |
200 |
201 |
202 |
--------------------------------------------------------------------------------
/Services/PluginService.cs:
--------------------------------------------------------------------------------
1 | using System.IO;
2 | using System.Runtime.Loader;
3 | using System.Text;
4 | using System.Text.Json;
5 | using GreenLuma_Manager.Models;
6 | using GreenLuma_Manager.Plugins;
7 |
8 | namespace GreenLuma_Manager.Services;
9 |
10 | public class PluginService
11 | {
12 | private static readonly string PluginsDir = Path.Combine(
13 | Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
14 | "GLM_Manager",
15 | "plugins");
16 |
17 | private static readonly string PluginsConfigPath = Path.Combine(
18 | Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
19 | "GLM_Manager",
20 | "plugins.json");
21 |
22 | private static readonly string PendingDeletesPath = Path.Combine(
23 | Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
24 | "GLM_Manager",
25 | "pending_deletes.json");
26 |
27 | private static readonly List<(PluginInfo Info, IPlugin? Instance, AssemblyLoadContext? Context)> LoadedPlugins = [];
28 | private static List _pluginInfos = [];
29 |
30 | public static void Initialize()
31 | {
32 | try
33 | {
34 | EnsurePluginsDirectoryExists();
35 | CleanupPendingDeletes();
36 | _pluginInfos = LoadPluginInfos();
37 | LoadPlugins();
38 | }
39 | catch
40 | {
41 | // ignored
42 | }
43 | }
44 |
45 | private static void EnsurePluginsDirectoryExists()
46 | {
47 | if (!Directory.Exists(PluginsDir)) Directory.CreateDirectory(PluginsDir);
48 | }
49 |
50 | private static List LoadPluginInfos()
51 | {
52 | try
53 | {
54 | if (!File.Exists(PluginsConfigPath)) return [];
55 | var json = File.ReadAllText(PluginsConfigPath, Encoding.UTF8);
56 | return JsonSerializer.Deserialize>(json) ?? [];
57 | }
58 | catch
59 | {
60 | return [];
61 | }
62 | }
63 |
64 | private static void SavePluginInfos()
65 | {
66 | try
67 | {
68 | var json = JsonSerializer.Serialize(_pluginInfos);
69 | File.WriteAllText(PluginsConfigPath, json, Encoding.UTF8);
70 | }
71 | catch
72 | {
73 | // ignored
74 | }
75 | }
76 |
77 | private static void LoadPlugins()
78 | {
79 | foreach (var pluginInfo in _pluginInfos.Where(p => p.IsEnabled))
80 | try
81 | {
82 | var pluginPath = Path.Combine(PluginsDir, pluginInfo.FileName);
83 | if (!File.Exists(pluginPath)) continue;
84 |
85 | var context = new AssemblyLoadContext($"Plugin_{pluginInfo.Id}", true);
86 | var assembly = context.LoadFromAssemblyPath(pluginPath);
87 |
88 | var pluginType = assembly.GetTypes()
89 | .FirstOrDefault(t =>
90 | typeof(IPlugin).IsAssignableFrom(t) && t is { IsInterface: false, IsAbstract: false });
91 |
92 | if (pluginType == null) continue;
93 |
94 | var instance = (IPlugin?)Activator.CreateInstance(pluginType);
95 | if (instance == null) continue;
96 |
97 | instance.Initialize();
98 | LoadedPlugins.Add((pluginInfo, instance, context));
99 | }
100 | catch
101 | {
102 | // ignored
103 | }
104 | }
105 |
106 | public static void OnApplicationStartup()
107 | {
108 | foreach (var (_, instance, _) in LoadedPlugins)
109 | try
110 | {
111 | instance?.OnApplicationStartup();
112 | }
113 | catch
114 | {
115 | // ignored
116 | }
117 | }
118 |
119 | public static void OnApplicationShutdown()
120 | {
121 | foreach (var (_, instance, context) in LoadedPlugins)
122 | try
123 | {
124 | instance?.OnApplicationShutdown();
125 | context?.Unload();
126 | }
127 | catch
128 | {
129 | // ignored
130 | }
131 |
132 | LoadedPlugins.Clear();
133 | }
134 |
135 | public static List GetAllPlugins()
136 | {
137 | return [.. _pluginInfos];
138 | }
139 |
140 | public static string ImportPlugin(string sourcePath)
141 | {
142 | try
143 | {
144 | if (!File.Exists(sourcePath)) return "Plugin file not found";
145 |
146 | var fileName = Path.GetFileName(sourcePath);
147 | var pluginId = Guid.NewGuid().ToString("N");
148 | var targetPath = Path.Combine(PluginsDir, $"{pluginId}_{fileName}");
149 |
150 | var manifest = ExtractManifest(sourcePath);
151 | if (manifest == null) return "Invalid plugin: Missing manifest or IPlugin implementation";
152 |
153 | if (_pluginInfos.Any(p => string.Equals(p.Name, manifest.Name, StringComparison.OrdinalIgnoreCase)))
154 | return $"Plugin '{manifest.Name}' is already installed";
155 |
156 | File.Copy(sourcePath, targetPath, true);
157 |
158 | var pluginInfo = new PluginInfo
159 | {
160 | Name = manifest.Name,
161 | Version = manifest.Version,
162 | Author = manifest.Author,
163 | Description = manifest.Description,
164 | FileName = Path.GetFileName(targetPath),
165 | IsEnabled = true,
166 | Id = pluginId
167 | };
168 |
169 | _pluginInfos.Add(pluginInfo);
170 | SavePluginInfos();
171 |
172 | return string.Empty;
173 | }
174 | catch (Exception ex)
175 | {
176 | return $"Import failed: {ex.Message}";
177 | }
178 | }
179 |
180 | private static PluginManifest? ExtractManifest(string assemblyPath)
181 | {
182 | AssemblyLoadContext? context = null;
183 | try
184 | {
185 | context = new AssemblyLoadContext(null, true);
186 | var assembly = context.LoadFromAssemblyPath(assemblyPath);
187 |
188 | var pluginType = assembly.GetTypes()
189 | .FirstOrDefault(t =>
190 | typeof(IPlugin).IsAssignableFrom(t) && t is { IsInterface: false, IsAbstract: false });
191 |
192 | if (pluginType == null) return null;
193 |
194 | var instance = (IPlugin?)Activator.CreateInstance(pluginType);
195 | if (instance == null) return null;
196 |
197 | return new PluginManifest
198 | {
199 | Name = instance.Name,
200 | Version = instance.Version,
201 | Author = instance.Author,
202 | Description = instance.Description
203 | };
204 | }
205 | catch
206 | {
207 | return null;
208 | }
209 | finally
210 | {
211 | context?.Unload();
212 | }
213 | }
214 |
215 | public static void RemovePlugin(PluginInfo pluginInfo)
216 | {
217 | try
218 | {
219 | var pluginPath = Path.Combine(PluginsDir, pluginInfo.FileName);
220 |
221 | var loaded = LoadedPlugins.FirstOrDefault(p => p.Info.Id == pluginInfo.Id);
222 | if (loaded.Context != null)
223 | {
224 | try
225 | {
226 | loaded.Instance?.OnApplicationShutdown();
227 | loaded.Context.Unload();
228 | }
229 | catch
230 | {
231 | // ignored
232 | }
233 |
234 | LoadedPlugins.Remove(loaded);
235 |
236 | GC.Collect();
237 | GC.WaitForPendingFinalizers();
238 | }
239 |
240 | _pluginInfos.RemoveAll(p => p.Id == pluginInfo.Id);
241 | SavePluginInfos();
242 |
243 | if (File.Exists(pluginPath))
244 | try
245 | {
246 | File.Delete(pluginPath);
247 | }
248 | catch
249 | {
250 | MarkForDeletion(pluginPath);
251 | }
252 | }
253 | catch
254 | {
255 | // ignored
256 | }
257 | }
258 |
259 | private static void MarkForDeletion(string path)
260 | {
261 | try
262 | {
263 | var list = LoadPendingDeletes();
264 | if (!list.Contains(path)) list.Add(path);
265 | SavePendingDeletes(list);
266 | }
267 | catch
268 | {
269 | // ignored
270 | }
271 | }
272 |
273 | private static List LoadPendingDeletes()
274 | {
275 | try
276 | {
277 | if (!File.Exists(PendingDeletesPath)) return new List();
278 | var json = File.ReadAllText(PendingDeletesPath, Encoding.UTF8);
279 | return JsonSerializer.Deserialize>(json) ?? new List();
280 | }
281 | catch
282 | {
283 | return new List();
284 | }
285 | }
286 |
287 | private static void SavePendingDeletes(List paths)
288 | {
289 | try
290 | {
291 | var json = JsonSerializer.Serialize(paths);
292 | File.WriteAllText(PendingDeletesPath, json, Encoding.UTF8);
293 | }
294 | catch
295 | {
296 | // ignored
297 | }
298 | }
299 |
300 | private static void CleanupPendingDeletes()
301 | {
302 | try
303 | {
304 | if (!File.Exists(PendingDeletesPath)) return;
305 |
306 | var paths = LoadPendingDeletes();
307 | var remaining = new List();
308 |
309 | foreach (var path in paths)
310 | try
311 | {
312 | if (File.Exists(path)) File.Delete(path);
313 | }
314 | catch
315 | {
316 | remaining.Add(path);
317 | }
318 |
319 | if (remaining.Count > 0)
320 | SavePendingDeletes(remaining);
321 | else
322 | File.Delete(PendingDeletesPath);
323 | }
324 | catch
325 | {
326 | // ignored
327 | }
328 | }
329 |
330 | public static void TogglePlugin(PluginInfo pluginInfo, bool enabled)
331 | {
332 | try
333 | {
334 | var info = _pluginInfos.FirstOrDefault(p => p.Id == pluginInfo.Id);
335 | if (info == null) return;
336 |
337 | info.IsEnabled = enabled;
338 | SavePluginInfos();
339 |
340 | if (!enabled)
341 | {
342 | var loaded = LoadedPlugins.FirstOrDefault(p => p.Info.Id == pluginInfo.Id);
343 | if (loaded.Context != null)
344 | {
345 | try
346 | {
347 | loaded.Instance?.OnApplicationShutdown();
348 | loaded.Context.Unload();
349 | }
350 | catch
351 | {
352 | // ignored
353 | }
354 |
355 | LoadedPlugins.Remove(loaded);
356 | }
357 | }
358 | }
359 | catch
360 | {
361 | // ignored
362 | }
363 | }
364 |
365 | public static List GetEnabledPlugins()
366 | {
367 | return
368 | [
369 | .. LoadedPlugins
370 | .Where(p => p.Instance != null)
371 | .Select(p => p.Instance!)
372 | ];
373 | }
374 | }
--------------------------------------------------------------------------------
/Services/GreenLumaService.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics;
2 | using System.IO;
3 | using System.Text.RegularExpressions;
4 | using GreenLuma_Manager.Models;
5 |
6 | namespace GreenLuma_Manager.Services;
7 |
8 | public partial class GreenLumaService
9 | {
10 | private const int ProcessKillTimeoutMs = 5000;
11 | public const int AppListLimit = 134;
12 | private static readonly string[] SteamProcessNames = ["steam", "steamwebhelper", "steamerrorfilereporter"];
13 |
14 | [GeneratedRegex(@"[A-Za-z]:\\[^""\r\n]+?\.dll", RegexOptions.IgnoreCase)]
15 | private static partial Regex DllPathRegex();
16 |
17 | public static bool IsAppListGenerated(Config config)
18 | {
19 | if (string.IsNullOrWhiteSpace(config.GreenLumaPath))
20 | return false;
21 |
22 | var appListPath = Path.Combine(config.GreenLumaPath, "AppList");
23 |
24 | return Directory.Exists(appListPath) &&
25 | Directory.GetFiles(appListPath, "*.txt").Length > 0;
26 | }
27 |
28 | public static async Task GenerateAppListAsync(Profile? profile, Config? config)
29 | {
30 | if (profile == null || config == null || string.IsNullOrWhiteSpace(config.GreenLumaPath))
31 | return -1;
32 |
33 | try
34 | {
35 | var appListPath = Path.Combine(config.GreenLumaPath, "AppList");
36 | Directory.CreateDirectory(appListPath);
37 |
38 | foreach (var file in Directory.GetFiles(appListPath, "*.txt"))
39 | File.Delete(file);
40 |
41 | var allAppIds = new List();
42 |
43 | foreach (var game in profile.Games)
44 | {
45 | allAppIds.Add(game.AppId);
46 | allAppIds.AddRange(game.Depots);
47 | }
48 |
49 | var totalCount = allAppIds.Count;
50 |
51 | var limitedAppIds = allAppIds.Take(AppListLimit).ToList();
52 |
53 | for (var i = 0; i < limitedAppIds.Count; i++)
54 | {
55 | var filePath = Path.Combine(appListPath, $"{i}.txt");
56 | await File.WriteAllTextAsync(filePath, limitedAppIds[i]);
57 | }
58 |
59 | return totalCount;
60 | }
61 | catch
62 | {
63 | return -1;
64 | }
65 | }
66 |
67 | public static async Task LaunchGreenLumaAsync(Config config)
68 | {
69 | return await Task.Run(() =>
70 | {
71 | try
72 | {
73 | if (!ValidatePaths(config))
74 | return false;
75 |
76 | KillSteam(config);
77 |
78 | return LaunchInjector(config);
79 | }
80 | catch
81 | {
82 | // ignored
83 | return false;
84 | }
85 | });
86 | }
87 |
88 | private static bool ValidatePaths(Config config)
89 | {
90 | if (string.IsNullOrWhiteSpace(config.SteamPath) ||
91 | string.IsNullOrWhiteSpace(config.GreenLumaPath))
92 | return false;
93 |
94 | var steamExePath = Path.Combine(config.SteamPath, "Steam.exe");
95 | var injectorPath = Path.Combine(config.GreenLumaPath, "DLLInjector.exe");
96 |
97 | return File.Exists(steamExePath) && File.Exists(injectorPath);
98 | }
99 |
100 | private static bool LaunchInjector(Config config)
101 | {
102 | var injectorPath = Path.Combine(config.GreenLumaPath, "DLLInjector.exe");
103 |
104 | if (!File.Exists(injectorPath))
105 | return false;
106 |
107 | UpdateInjectorIni(config);
108 |
109 | Process.Start(new ProcessStartInfo
110 | {
111 | FileName = injectorPath,
112 | WorkingDirectory = config.GreenLumaPath,
113 | UseShellExecute = false,
114 | CreateNoWindow = true
115 | });
116 |
117 | return true;
118 | }
119 |
120 | private static void KillSteam(Config config)
121 | {
122 | try
123 | {
124 | var steamExePath = Path.Combine(config.SteamPath, "Steam.exe");
125 |
126 | if (File.Exists(steamExePath))
127 | try
128 | {
129 | Process.Start(new ProcessStartInfo
130 | {
131 | FileName = steamExePath,
132 | Arguments = "-shutdown",
133 | UseShellExecute = false,
134 | CreateNoWindow = true
135 | });
136 | Thread.Sleep(2000);
137 | }
138 | catch
139 | {
140 | // ignored
141 | }
142 |
143 | foreach (var processName in SteamProcessNames) KillProcessesByName(processName);
144 | }
145 | catch
146 | {
147 | // ignored
148 | }
149 | }
150 |
151 | private static void KillProcessesByName(string processName)
152 | {
153 | foreach (var process in Process.GetProcessesByName(processName))
154 | try
155 | {
156 | process.Kill();
157 | process.WaitForExit(ProcessKillTimeoutMs);
158 | }
159 | catch
160 | {
161 | // ignored
162 | }
163 | }
164 |
165 | private static bool AreSameDirectory(string path1, string path2)
166 | {
167 | try
168 | {
169 | var fullPath1 = Path.GetFullPath(path1)
170 | .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
171 | var fullPath2 = Path.GetFullPath(path2)
172 | .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
173 | return string.Equals(fullPath1, fullPath2, StringComparison.OrdinalIgnoreCase);
174 | }
175 | catch
176 | {
177 | // ignored
178 | return false;
179 | }
180 | }
181 |
182 | private static void UpdateInjectorIni(Config config)
183 | {
184 | try
185 | {
186 | var iniPath = Path.Combine(config.GreenLumaPath, "DLLInjector.ini");
187 |
188 | if (!File.Exists(iniPath))
189 | return;
190 |
191 | var lines = File.ReadAllLines(iniPath).ToList();
192 | var dllValue = ExtractDllValue(lines);
193 | var settings = BuildInjectorSettings(config, dllValue);
194 | var updatedLines = ApplySettings(lines, settings);
195 |
196 | File.WriteAllLines(iniPath, updatedLines);
197 | }
198 | catch
199 | {
200 | // ignored
201 | }
202 | }
203 |
204 | private static string? ExtractDllValue(List lines)
205 | {
206 | try
207 | {
208 | foreach (var line in lines)
209 | {
210 | var trimmed = line.Trim();
211 |
212 | if (trimmed.StartsWith("Dll", StringComparison.OrdinalIgnoreCase))
213 | {
214 | var equalsIndex = line.IndexOf('=');
215 | if (equalsIndex >= 0 && equalsIndex < line.Length - 1)
216 | {
217 | var raw = line[(equalsIndex + 1)..].Trim();
218 | var cleaned = CleanDllValue(raw);
219 | return cleaned;
220 | }
221 |
222 | break;
223 | }
224 | }
225 | }
226 | catch
227 | {
228 | // ignored
229 | }
230 |
231 | return null;
232 | }
233 |
234 | private static string CleanDllValue(string raw)
235 | {
236 | if (string.IsNullOrWhiteSpace(raw))
237 | return string.Empty;
238 |
239 | var s = raw.Trim();
240 | s = s.Trim('"', '\'', ' ');
241 |
242 | try
243 | {
244 | var m = DllPathRegex().Match(s);
245 |
246 | if (m.Success) return m.Value;
247 | }
248 | catch
249 | {
250 | // ignored
251 | }
252 |
253 | return s;
254 | }
255 |
256 | private static Dictionary BuildInjectorSettings(Config config, string? dllValue)
257 | {
258 | var useSeparatePaths = !AreSameDirectory(config.SteamPath, config.GreenLumaPath) ||
259 | (!string.IsNullOrWhiteSpace(dllValue) && Path.IsPathRooted(dllValue));
260 |
261 | var steamExePath = Path.Combine(config.SteamPath, "Steam.exe");
262 |
263 | var settings = new Dictionary
264 | {
265 | ["FileToCreate_1"] = " NoQuestion.bin"
266 | };
267 |
268 | if (useSeparatePaths)
269 | {
270 | settings["UseFullPathsFromIni"] = " 1";
271 | settings["Exe"] = $" \"{steamExePath}\"";
272 |
273 | if (!string.IsNullOrWhiteSpace(dllValue))
274 | {
275 | var candidate = dllValue.Trim();
276 |
277 | bool rooted;
278 | try
279 | {
280 | rooted = Path.IsPathRooted(candidate);
281 | }
282 | catch
283 | {
284 | // ignored
285 | rooted = false;
286 | }
287 |
288 | if (rooted)
289 | {
290 | var full = candidate;
291 | try
292 | {
293 | full = Path.GetFullPath(candidate);
294 | }
295 | catch
296 | {
297 | // ignored
298 | }
299 |
300 | settings["Dll"] = $" \"{full}\"";
301 | }
302 | else
303 | {
304 | var fullDllPath = Path.Combine(config.GreenLumaPath, candidate);
305 | try
306 | {
307 | fullDllPath = Path.GetFullPath(fullDllPath);
308 | }
309 | catch
310 | {
311 | // ignored
312 | }
313 |
314 | settings["Dll"] = $" \"{fullDllPath}\"";
315 | }
316 | }
317 | }
318 | else
319 | {
320 | settings["UseFullPathsFromIni"] = " 0";
321 | settings["Exe"] = " Steam.exe";
322 |
323 | if (!string.IsNullOrWhiteSpace(dllValue)) settings["Dll"] = $" {dllValue}";
324 | }
325 |
326 | if (config.NoHook)
327 | ApplyStealthModeSettings(settings);
328 | else
329 | ApplyNormalModeSettings(settings);
330 |
331 | return settings;
332 | }
333 |
334 | private static void ApplyStealthModeSettings(Dictionary settings)
335 | {
336 | settings["CommandLine"] = "";
337 | settings["WaitForProcessTermination"] = " 0";
338 | settings["EnableFakeParentProcess"] = " 1";
339 | settings["EnableMitigationsOnChildProcess"] = " 0";
340 | settings["CreateFiles"] = " 2";
341 | settings["FileToCreate_2"] = " StealthMode.bin";
342 | }
343 |
344 | private static void ApplyNormalModeSettings(Dictionary settings)
345 | {
346 | settings["CommandLine"] = " -inhibitbootstrap";
347 | settings["WaitForProcessTermination"] = " 1";
348 | settings["EnableFakeParentProcess"] = " 0";
349 | settings["CreateFiles"] = " 1";
350 | settings.TryAdd("FileToCreate_2", "");
351 | }
352 |
353 | private static List ApplySettings(List originalLines, Dictionary settings)
354 | {
355 | var result = new List();
356 |
357 | foreach (var line in originalLines)
358 | {
359 | var trimmed = line.Trim();
360 | var matched = false;
361 |
362 | if (!string.IsNullOrWhiteSpace(trimmed) && trimmed[0] != '#' && trimmed.Contains('='))
363 | {
364 | var equalsIndex = trimmed.IndexOf('=');
365 | var key = trimmed[..equalsIndex].Trim();
366 |
367 | foreach (var setting in settings)
368 | if (string.Equals(key, setting.Key, StringComparison.OrdinalIgnoreCase))
369 | {
370 | result.Add($"{setting.Key}={setting.Value}");
371 | matched = true;
372 | break;
373 | }
374 | }
375 |
376 | if (!matched) result.Add(line);
377 | }
378 |
379 | return result;
380 | }
381 | }
--------------------------------------------------------------------------------
/Services/IconCacheService.cs:
--------------------------------------------------------------------------------
1 | using System.IO;
2 | using System.Net.Http;
3 | using System.Windows.Media.Imaging;
4 |
5 | namespace GreenLuma_Manager.Services;
6 |
7 | public class IconCacheService
8 | {
9 | private static readonly string IconCacheDir = Path.Combine(
10 | Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
11 | "GLM_Manager",
12 | "icons");
13 |
14 | private static readonly HttpClient Client = new();
15 |
16 | static IconCacheService()
17 | {
18 | Client.DefaultRequestHeaders.Add("User-Agent",
19 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
20 | Client.Timeout = TimeSpan.FromSeconds(10);
21 | }
22 |
23 | public static async Task DownloadAndCacheIconAsync(string appId, string iconUrl)
24 | {
25 | if (string.IsNullOrEmpty(appId) || string.IsNullOrEmpty(iconUrl))
26 | return null;
27 | if (!iconUrl.StartsWith("http", StringComparison.OrdinalIgnoreCase))
28 | return null;
29 | try
30 | {
31 | EnsureIconCacheDirectoryExists();
32 | var extension = GetImageExtension(iconUrl);
33 | var filePath = Path.Combine(IconCacheDir, appId + extension);
34 |
35 | if (File.Exists(filePath) && new FileInfo(filePath).Length > 0)
36 | return filePath;
37 |
38 | var data = await TryDownloadWithRetries(iconUrl, 3, TimeSpan.FromSeconds(3)).ConfigureAwait(false);
39 | if (data is { Length: > 0 })
40 | {
41 | await File.WriteAllBytesAsync(filePath, data).ConfigureAwait(false);
42 | return filePath;
43 | }
44 | }
45 | catch
46 | {
47 | // ignored
48 | }
49 |
50 | return null;
51 | }
52 |
53 | public static async Task CacheIconForGameAsync(GameDetails details)
54 | {
55 | return await CacheIconForGameRecursiveAsync(details, 0).ConfigureAwait(false);
56 | }
57 |
58 | private static async Task CacheIconForGameRecursiveAsync(GameDetails details, int depth)
59 | {
60 | if (depth > 2) return null;
61 |
62 | EnsureIconCacheDirectoryExists();
63 |
64 | var isDlc = string.Equals(details.Type, "DLC", StringComparison.OrdinalIgnoreCase);
65 | var cached = GetCachedIconPath(details.AppId, isDlc);
66 | if (!string.IsNullOrEmpty(cached)) return cached;
67 |
68 | var candidates = new List();
69 |
70 | if (isDlc)
71 | {
72 | if (!string.IsNullOrEmpty(details.HeroHash))
73 | {
74 | candidates.Add(new IconCandidate(
75 | $"https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/{details.AppId}/{details.HeroHash}/hero_capsule.jpg"));
76 | candidates.Add(new IconCandidate(
77 | $"https://cdn.cloudflare.steamstatic.com/steam/apps/{details.AppId}/{details.HeroHash}/hero_capsule.jpg"));
78 | }
79 |
80 | if (!string.IsNullOrEmpty(details.MainHash))
81 | {
82 | candidates.Add(new IconCandidate(
83 | $"https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/{details.AppId}/{details.MainHash}/capsule_616x353.jpg"));
84 | candidates.Add(new IconCandidate(
85 | $"https://cdn.cloudflare.steamstatic.com/steam/apps/{details.AppId}/{details.MainHash}/capsule_616x353.jpg"));
86 | }
87 | else
88 | {
89 | candidates.Add(new IconCandidate(
90 | $"https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/{details.AppId}/capsule_616x353.jpg"));
91 | candidates.Add(new IconCandidate(
92 | $"https://cdn.cloudflare.steamstatic.com/steam/apps/{details.AppId}/capsule_616x353.jpg"));
93 | }
94 |
95 | if (!string.IsNullOrEmpty(details.HeaderImage))
96 | {
97 | candidates.Add(new IconCandidate(
98 | $"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/{details.AppId}/{details.HeaderImage}"));
99 | candidates.Add(new IconCandidate(
100 | $"https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/{details.AppId}/{details.HeaderImage}"));
101 | candidates.Add(new IconCandidate(
102 | $"https://cdn.cloudflare.steamstatic.com/steam/apps/{details.AppId}/{details.HeaderImage}"));
103 | }
104 |
105 | candidates.Add(new IconCandidate(
106 | $"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/{details.AppId}/header.jpg"));
107 | candidates.Add(new IconCandidate(
108 | $"https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/{details.AppId}/header.jpg"));
109 | candidates.Add(new IconCandidate(
110 | $"https://cdn.cloudflare.steamstatic.com/steam/apps/{details.AppId}/header.jpg"));
111 | }
112 | else
113 | {
114 | if (!string.IsNullOrEmpty(details.ClientIconHash))
115 | {
116 | candidates.Add(new IconCandidate(
117 | $"https://shared.fastly.steamstatic.com/community_assets/images/apps/{details.AppId}/{details.ClientIconHash}.ico",
118 | 64));
119 | candidates.Add(new IconCandidate(
120 | $"https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/{details.AppId}/{details.ClientIconHash}.ico",
121 | 64));
122 | }
123 |
124 | if (!string.IsNullOrEmpty(details.HeroHash))
125 | {
126 | candidates.Add(new IconCandidate(
127 | $"https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/{details.AppId}/{details.HeroHash}/hero_capsule.jpg"));
128 | candidates.Add(new IconCandidate(
129 | $"https://cdn.cloudflare.steamstatic.com/steam/apps/{details.AppId}/{details.HeroHash}/hero_capsule.jpg"));
130 | }
131 |
132 | if (!string.IsNullOrEmpty(details.MainHash))
133 | {
134 | candidates.Add(new IconCandidate(
135 | $"https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/{details.AppId}/{details.MainHash}/capsule_616x353.jpg"));
136 | candidates.Add(new IconCandidate(
137 | $"https://cdn.cloudflare.steamstatic.com/steam/apps/{details.AppId}/{details.MainHash}/capsule_616x353.jpg"));
138 | }
139 | else
140 | {
141 | candidates.Add(new IconCandidate(
142 | $"https://shared.fastly.steamstatic.com/store_item_assets/steam/apps/{details.AppId}/capsule_616x353.jpg"));
143 | candidates.Add(new IconCandidate(
144 | $"https://cdn.cloudflare.steamstatic.com/steam/apps/{details.AppId}/capsule_616x353.jpg"));
145 | }
146 |
147 | if (!string.IsNullOrEmpty(details.HeaderImage))
148 | {
149 | candidates.Add(new IconCandidate(
150 | $"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/{details.AppId}/{details.HeaderImage}"));
151 | candidates.Add(new IconCandidate(
152 | $"https://cdn.cloudflare.steamstatic.com/steam/apps/{details.AppId}/{details.HeaderImage}"));
153 | }
154 |
155 | candidates.Add(new IconCandidate(
156 | $"https://shared.akamai.steamstatic.com/store_item_assets/steam/apps/{details.AppId}/header.jpg"));
157 | candidates.Add(new IconCandidate(
158 | $"https://cdn.cloudflare.steamstatic.com/steam/apps/{details.AppId}/header.jpg"));
159 | }
160 |
161 | foreach (var candidate in candidates)
162 | try
163 | {
164 | var data = await TryDownloadWithRetries(candidate.Url, 2, TimeSpan.FromMilliseconds(500))
165 | .ConfigureAwait(false);
166 | if (data == null || data.Length == 0) continue;
167 |
168 | if (candidate.MinSize > 0 && !IsValidImageSize(data, candidate.MinSize))
169 | continue;
170 |
171 | var extension = GetImageExtension(candidate.Url);
172 | var filePath = Path.Combine(IconCacheDir, details.AppId + extension);
173 | await File.WriteAllBytesAsync(filePath, data).ConfigureAwait(false);
174 | return filePath;
175 | }
176 | catch
177 | {
178 | // ignored
179 | }
180 |
181 | if (isDlc && !string.IsNullOrEmpty(details.ParentAppId) && uint.TryParse(details.ParentAppId, out var parentId))
182 | try
183 | {
184 | var parentDetails = await SteamService.Instance.GetGameDetailsAsync(parentId).ConfigureAwait(false);
185 | if (parentDetails != null)
186 | {
187 | var parentPath = await CacheIconForGameRecursiveAsync(parentDetails, depth + 1)
188 | .ConfigureAwait(false);
189 | if (!string.IsNullOrEmpty(parentPath) && File.Exists(parentPath))
190 | {
191 | var ext = Path.GetExtension(parentPath);
192 | var myPath = Path.Combine(IconCacheDir, details.AppId + ext);
193 | File.Copy(parentPath, myPath, true);
194 | return myPath;
195 | }
196 | }
197 | }
198 | catch
199 | {
200 | // ignored
201 | }
202 |
203 | return null;
204 | }
205 |
206 | private static bool IsValidImageSize(byte[] data, int minSize)
207 | {
208 | try
209 | {
210 | using var ms = new MemoryStream(data);
211 | var decoder = BitmapDecoder.Create(ms, BitmapCreateOptions.PreservePixelFormat, BitmapCacheOption.OnLoad);
212 | if (decoder.Frames.Count == 0) return false;
213 | var frame = decoder.Frames[0];
214 | return frame.PixelWidth >= minSize;
215 | }
216 | catch
217 | {
218 | return false;
219 | }
220 | }
221 |
222 | private static async Task TryDownloadWithRetries(string url, int maxAttempts, TimeSpan delay)
223 | {
224 | for (var attempt = 1; attempt <= maxAttempts; attempt++)
225 | {
226 | try
227 | {
228 | using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(4));
229 | using var resp = await Client.GetAsync(url, cts.Token).ConfigureAwait(false);
230 | if (!resp.IsSuccessStatusCode)
231 | return null;
232 | var data = await resp.Content.ReadAsByteArrayAsync(cts.Token).ConfigureAwait(false);
233 | if (data.Length > 0)
234 | return data;
235 | }
236 | catch
237 | {
238 | if (attempt == maxAttempts)
239 | break;
240 | }
241 |
242 | await Task.Delay(delay).ConfigureAwait(false);
243 | }
244 |
245 | return null;
246 | }
247 |
248 | public static string? GetCachedIconPath(string appId, bool preferJpg = false)
249 | {
250 | if (string.IsNullOrEmpty(appId)) return null;
251 |
252 | try
253 | {
254 | if (!Directory.Exists(IconCacheDir)) return null;
255 |
256 | var preferences = preferJpg
257 | ? new[] { ".jpg", ".jpeg", ".ico", ".png", ".webp" }
258 | : new[] { ".ico", ".jpg", ".jpeg", ".png", ".webp" };
259 |
260 | foreach (var ext in preferences)
261 | {
262 | var filePath = Path.Combine(IconCacheDir, $"{appId}{ext}");
263 | if (File.Exists(filePath) && new FileInfo(filePath).Length > 0)
264 | return filePath;
265 | }
266 | }
267 | catch
268 | {
269 | // ignored
270 | }
271 |
272 | return null;
273 | }
274 |
275 | public static void DeleteCachedIcon(string appId)
276 | {
277 | if (string.IsNullOrEmpty(appId)) return;
278 |
279 | try
280 | {
281 | if (!Directory.Exists(IconCacheDir)) return;
282 |
283 | string[] extensions = [".jpg", ".ico", ".jpeg", ".png", ".gif", ".webp"];
284 |
285 | foreach (var ext in extensions)
286 | {
287 | var filePath = Path.Combine(IconCacheDir, $"{appId}{ext}");
288 | if (File.Exists(filePath))
289 | {
290 | File.Delete(filePath);
291 | break;
292 | }
293 | }
294 | }
295 | catch
296 | {
297 | // ignored
298 | }
299 | }
300 |
301 | public static void DeleteUnusedIcons(HashSet validAppIds)
302 | {
303 | try
304 | {
305 | if (!Directory.Exists(IconCacheDir)) return;
306 | var files = Directory.GetFiles(IconCacheDir);
307 | foreach (var file in files)
308 | try
309 | {
310 | var name = Path.GetFileNameWithoutExtension(file);
311 | if (!validAppIds.Contains(name))
312 | File.Delete(file);
313 | }
314 | catch
315 | {
316 | // ignored
317 | }
318 | }
319 | catch
320 | {
321 | // ignored
322 | }
323 | }
324 |
325 | private static void EnsureIconCacheDirectoryExists()
326 | {
327 | if (!Directory.Exists(IconCacheDir)) Directory.CreateDirectory(IconCacheDir);
328 | }
329 |
330 | private static string GetImageExtension(string url)
331 | {
332 | try
333 | {
334 | var lower = url.ToLower();
335 | if (lower.Contains(".ico")) return ".ico";
336 | if (lower.Contains(".jpg") || lower.Contains("jpeg")) return ".jpg";
337 | if (lower.Contains(".png")) return ".png";
338 | if (lower.Contains(".gif")) return ".gif";
339 | if (lower.Contains(".webp")) return ".webp";
340 | return ".jpg";
341 | }
342 | catch
343 | {
344 | return ".jpg";
345 | }
346 | }
347 |
348 | private record IconCandidate(string Url, int MinSize = 0);
349 | }
--------------------------------------------------------------------------------
/Services/SearchService.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Concurrent;
2 | using System.Net.Http;
3 | using GreenLuma_Manager.Models;
4 | using Newtonsoft.Json.Linq;
5 |
6 | namespace GreenLuma_Manager.Services;
7 |
8 | public class CacheEntry
9 | {
10 | public DateTime Expiry { get; set; }
11 | public T Data { get; set; } = default!;
12 | }
13 |
14 | public static class SteamApiCache
15 | {
16 | internal static readonly ConcurrentDictionary> Cache = new();
17 | private static readonly ConcurrentDictionary> TaskCache = new();
18 | private static readonly TimeSpan CacheDurationLocal = TimeSpan.FromMinutes(30);
19 |
20 | public static async Task GetOrAddAsync(string key, Func> fetchFunc)
21 | {
22 | if (Cache.TryGetValue(key, out var entry))
23 | if (DateTime.Now < entry.Expiry && entry.Data is T cachedVal)
24 | return cachedVal;
25 |
26 | var task = TaskCache.GetOrAdd(key, _ => FetchAndCacheAsync(key, fetchFunc));
27 | try
28 | {
29 | var result = await task.ConfigureAwait(false);
30 | return (T)result;
31 | }
32 | finally
33 | {
34 | TaskCache.TryRemove(key, out _);
35 | }
36 | }
37 |
38 | private static async Task