From e71dcce3e95a4c2fade801566841b721f1fd286f Mon Sep 17 00:00:00 2001
From: Marvin <52848568+mleem97@users.noreply.github.com>
Date: Wed, 8 Jul 2026 03:23:02 +0200
Subject: [PATCH 01/21] fix(security): harden process launching helpers
---
.../Services/SafeProcess.cs | 272 ++++++++++--------
1 file changed, 154 insertions(+), 118 deletions(-)
diff --git a/src/GregModmanager.Core/Services/SafeProcess.cs b/src/GregModmanager.Core/Services/SafeProcess.cs
index 12f4ec5..b8f83a9 100644
--- a/src/GregModmanager.Core/Services/SafeProcess.cs
+++ b/src/GregModmanager.Core/Services/SafeProcess.cs
@@ -1,118 +1,154 @@
-using System;
-using System.Diagnostics;
-using System.Threading.Tasks;
-
-namespace GregModmanager.Services;
-
-public static class SafeProcess
-{
- ///
- /// Opens a URL in the default browser safely, ensuring only http and https schemes are allowed.
- ///
- public static Task OpenUrlAsync(string url)
- {
- if (string.IsNullOrWhiteSpace(url)) return Task.CompletedTask;
-
- try
- {
- if (Uri.TryCreate(url, UriKind.Absolute, out var uri) &&
- (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
- {
- Process.Start(new ProcessStartInfo
- {
- FileName = uri.ToString(),
- UseShellExecute = true
- });
- }
- else
- {
- AppFileLog.Warn($"Blocked attempt to open insecure or invalid URL: {url}");
- }
- }
- catch (Exception ex)
- {
- AppFileLog.Error($"Failed to open URL: {url}", ex);
- }
-
- return Task.CompletedTask;
- }
-
- ///
- /// Opens a folder in the system's file explorer.
- ///
- public static void OpenFolder(string path)
- {
- if (string.IsNullOrWhiteSpace(path)) return;
-
- try
- {
- if (OperatingSystem.IsWindows())
- {
- Process.Start(new ProcessStartInfo
- {
- FileName = "explorer.exe",
- Arguments = $"\"{path}\"",
- UseShellExecute = false
- });
- }
- else
- {
- // For other OS, we might still need UseShellExecute for some scenarios,
- // but we should be careful. MAUI doesn't have a direct "OpenFolder" that works everywhere.
- Process.Start(new ProcessStartInfo
- {
- FileName = path,
- UseShellExecute = true
- });
- }
- }
- catch (Exception ex)
- {
- AppFileLog.Error($"Failed to open folder: {path}", ex);
- }
- }
-
- ///
- /// Specifically for Windows, opens explorer and selects a file.
- ///
- public static void OpenExplorerAndSelect(string filePath)
- {
- if (!OperatingSystem.IsWindows() || string.IsNullOrWhiteSpace(filePath)) return;
-
- try
- {
- Process.Start(new ProcessStartInfo
- {
- FileName = "explorer.exe",
- Arguments = $"/select,\"{filePath}\"",
- UseShellExecute = false
- });
- }
- catch (Exception ex)
- {
- AppFileLog.Error($"Failed to open explorer and select: {filePath}", ex);
- }
- }
-
- ///
- /// Launches an executable with UseShellExecute = false.
- ///
- public static void LaunchApp(string exePath, string arguments = "")
- {
- if (string.IsNullOrWhiteSpace(exePath)) return;
-
- try
- {
- Process.Start(new ProcessStartInfo
- {
- FileName = exePath,
- Arguments = arguments,
- UseShellExecute = false
- });
- }
- catch (Exception ex)
- {
- AppFileLog.Error($"Failed to launch app: {exePath}", ex);
- }
- }
-}
+using System;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+using System.Threading.Tasks;
+
+namespace GregModmanager.Services;
+
+public static class SafeProcess
+{
+ ///
+ /// Opens a URL in the default browser safely, ensuring only http and https schemes are allowed.
+ ///
+ public static Task OpenUrlAsync(string url)
+ {
+ if (string.IsNullOrWhiteSpace(url)) return Task.CompletedTask;
+
+ try
+ {
+ if (Uri.TryCreate(url, UriKind.Absolute, out var uri) && IsAllowedBrowserUri(uri))
+ {
+ StartShellOpen(uri.AbsoluteUri);
+ }
+ else
+ {
+ AppFileLog.Warn($"Blocked attempt to open insecure or invalid URL: {url}");
+ }
+ }
+ catch (Exception ex)
+ {
+ AppFileLog.Error($"Failed to open URL: {url}", ex);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ ///
+ /// Opens a folder in the system's file explorer.
+ ///
+ public static void OpenFolder(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path)) return;
+
+ try
+ {
+ var fullPath = Path.GetFullPath(path);
+ if (!Directory.Exists(fullPath))
+ {
+ AppFileLog.Warn($"Blocked attempt to open missing folder: {path}");
+ return;
+ }
+
+ StartShellOpen(fullPath);
+ }
+ catch (Exception ex)
+ {
+ AppFileLog.Error($"Failed to open folder: {path}", ex);
+ }
+ }
+
+ ///
+ /// Specifically for Windows, opens explorer and selects a file.
+ ///
+ public static void OpenExplorerAndSelect(string filePath)
+ {
+ if (!OperatingSystem.IsWindows() || string.IsNullOrWhiteSpace(filePath)) return;
+
+ try
+ {
+ var fullPath = Path.GetFullPath(filePath);
+ if (!File.Exists(fullPath) && !Directory.Exists(fullPath))
+ {
+ AppFileLog.Warn($"Blocked attempt to select missing file system entry: {filePath}");
+ return;
+ }
+
+ StartProcess("explorer.exe", new[] { $"/select,{fullPath}" });
+ }
+ catch (Exception ex)
+ {
+ AppFileLog.Error($"Failed to open explorer and select: {filePath}", ex);
+ }
+ }
+
+ ///
+ /// Launches a local executable with UseShellExecute = false.
+ ///
+ public static void LaunchApp(string exePath)
+ {
+ LaunchApp(exePath, Array.Empty());
+ }
+
+ ///
+ /// Launches a local executable with explicit arguments and UseShellExecute = false.
+ ///
+ [SuppressMessage("Security", "S2076:OS commands should not be vulnerable to command injection", Justification = "The executable path is normalized, required to exist locally, and arguments are passed through ArgumentList instead of a shell string.")]
+ public static void LaunchApp(string exePath, IReadOnlyList arguments)
+ {
+ if (string.IsNullOrWhiteSpace(exePath)) return;
+
+ try
+ {
+ var fullPath = Path.GetFullPath(exePath);
+ if (!File.Exists(fullPath))
+ {
+ AppFileLog.Warn($"Blocked attempt to launch missing executable: {exePath}");
+ return;
+ }
+
+ StartProcess(fullPath, arguments);
+ }
+ catch (Exception ex)
+ {
+ AppFileLog.Error($"Failed to launch app: {exePath}", ex);
+ }
+ }
+
+ private static bool IsAllowedBrowserUri(Uri uri)
+ {
+ return uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps;
+ }
+
+ private static void StartShellOpen(string target)
+ {
+ if (OperatingSystem.IsWindows())
+ {
+ StartProcess("rundll32.exe", new[] { "url.dll,FileProtocolHandler", target });
+ return;
+ }
+
+ if (OperatingSystem.IsMacOS())
+ {
+ StartProcess("open", new[] { target });
+ return;
+ }
+
+ StartProcess("xdg-open", new[] { target });
+ }
+
+ private static void StartProcess(string fileName, IEnumerable arguments)
+ {
+ var info = new ProcessStartInfo
+ {
+ FileName = fileName,
+ UseShellExecute = false
+ };
+
+ foreach (var argument in arguments)
+ {
+ info.ArgumentList.Add(argument);
+ }
+
+ Process.Start(info);
+ }
+}
From f6268dffd88f3dad401b1fa3b5e7084bf55943dd Mon Sep 17 00:00:00 2001
From: Marvin <52848568+mleem97@users.noreply.github.com>
Date: Wed, 8 Jul 2026 03:23:18 +0200
Subject: [PATCH 02/21] fix(core): use invariant telemetry identifiers
---
src/GregModmanager.Core/Services/TelemetryService.cs | 12 +++++++++---
1 file changed, 9 insertions(+), 3 deletions(-)
diff --git a/src/GregModmanager.Core/Services/TelemetryService.cs b/src/GregModmanager.Core/Services/TelemetryService.cs
index 432aaf5..e53e115 100644
--- a/src/GregModmanager.Core/Services/TelemetryService.cs
+++ b/src/GregModmanager.Core/Services/TelemetryService.cs
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
+using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
@@ -80,7 +81,12 @@ public async Task ReportCrashesAsync()
}
}
- public async Task TrackEventAsync(string eventName, object payload, Dictionary? extraLabels = null)
+ public Task TrackEventAsync(string eventName, object payload)
+ {
+ return TrackEventAsync(eventName, payload, extraLabels: null);
+ }
+
+ public async Task TrackEventAsync(string eventName, object payload, Dictionary? extraLabels)
{
if (!AppSettings.IsTelemetryEnabled()) return;
@@ -113,7 +119,7 @@ private static string GetMachineId()
var raw = $"{Environment.MachineName}-{Environment.UserName}";
using var sha = System.Security.Cryptography.SHA256.Create();
var hash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(raw));
- return BitConverter.ToString(hash).Replace("-", "").Substring(0, 12).ToLower();
+ return BitConverter.ToString(hash).Replace("-", string.Empty, StringComparison.Ordinal).Substring(0, 12).ToLower(CultureInfo.InvariantCulture);
}
private async Task PushToLokiAsync(string job, string line, Dictionary labels)
@@ -133,7 +139,7 @@ private async Task PushToLokiAsync(string job, string line, Dictionary
Date: Wed, 8 Jul 2026 03:23:29 +0200
Subject: [PATCH 03/21] refactor(core): expose preference keys as static
properties
---
src/GregModmanager.Core/Services/AppSettings.cs | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/GregModmanager.Core/Services/AppSettings.cs b/src/GregModmanager.Core/Services/AppSettings.cs
index 95fac56..4f78d87 100644
--- a/src/GregModmanager.Core/Services/AppSettings.cs
+++ b/src/GregModmanager.Core/Services/AppSettings.cs
@@ -4,9 +4,9 @@ namespace GregModmanager.Services;
public static class AppSettings
{
- public const string ModStoreEnabledKey = "ModStoreEnabled";
- public const string GameRootPathKey = "GameRootPath";
- public const string TelemetryEnabledKey = "TelemetryEnabled";
+ public static string ModStoreEnabledKey => "ModStoreEnabled";
+ public static string GameRootPathKey => "GameRootPath";
+ public static string TelemetryEnabledKey => "TelemetryEnabled";
public static bool IsLocalBuild =>
string.Equals(Environment.GetEnvironmentVariable("IS_LOCAL_BUILD"), "TRUE", StringComparison.OrdinalIgnoreCase);
From 99cab89709e8dbe2ecc0b42edfe5c0171bb34121 Mon Sep 17 00:00:00 2001
From: Marvin <52848568+mleem97@users.noreply.github.com>
Date: Wed, 8 Jul 2026 03:23:36 +0200
Subject: [PATCH 04/21] refactor(core): expose beta preference key as property
---
.../Services/BetaPluginSource.cs | 104 +++++++++---------
1 file changed, 52 insertions(+), 52 deletions(-)
diff --git a/src/GregModmanager.Core/Services/BetaPluginSource.cs b/src/GregModmanager.Core/Services/BetaPluginSource.cs
index cfd0337..7f9f067 100644
--- a/src/GregModmanager.Core/Services/BetaPluginSource.cs
+++ b/src/GregModmanager.Core/Services/BetaPluginSource.cs
@@ -1,52 +1,52 @@
-using System.Text.Json;
-using GregModmanager.Localization;
-using GregModmanager.Models;
-
-namespace GregModmanager.Services;
-
-///
-/// Beta distribution channel served from a custom backend.
-/// Configure base URL via Preferences.
-///
-public sealed class BetaPluginSource : IgregPluginChannelSource
-{
- /// Preferences key for the beta server base URL.
- public const string PrefKeyBetaServerUrl = "greg_beta_server_url";
-
- private static readonly HttpClient _http = new();
-
- public string ChannelName => "beta";
-
- public IReadOnlyList ListPlugins()
- {
- var url = S.Preferences.GetString(PrefKeyBetaServerUrl, string.Empty);
- if (string.IsNullOrWhiteSpace(url))
- {
- throw new InvalidOperationException(
- "Beta-Kanal: Server-URL ist noch nicht konfiguriert. " +
- "Setze die URL unter Einstellungen (Preferences-Key: greg_beta_server_url).");
- }
-
- var endpoint = url.TrimEnd('/') + "/api/plugins";
-
- try
- {
- // Use Task.Run to avoid deadlocks from sync-over-async on UI threads
- var plugins = Task.Run(async () =>
- {
- var response = await _http.GetAsync(endpoint).ConfigureAwait(false);
- response.EnsureSuccessStatusCode();
-
- var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
-
- return JsonSerializer.Deserialize(json, AppJsonContext.Default.ListPluginPackageInfo);
- }).GetAwaiter().GetResult();
-
- return plugins ?? new List();
- }
- catch (Exception ex)
- {
- throw new InvalidOperationException($"Beta-Kanal: Fehler beim Abrufen der Plugins (URL: {endpoint}). Details: {ex.Message}", ex);
- }
- }
-}
+using System.Text.Json;
+using GregModmanager.Localization;
+using GregModmanager.Models;
+
+namespace GregModmanager.Services;
+
+///
+/// Beta distribution channel served from a custom backend.
+/// Configure base URL via Preferences.
+///
+public sealed class BetaPluginSource : IgregPluginChannelSource
+{
+ /// Preferences key for the beta server base URL.
+ public static string PrefKeyBetaServerUrl => "greg_beta_server_url";
+
+ private static readonly HttpClient _http = new();
+
+ public string ChannelName => "beta";
+
+ public IReadOnlyList ListPlugins()
+ {
+ var url = S.Preferences.GetString(PrefKeyBetaServerUrl, string.Empty);
+ if (string.IsNullOrWhiteSpace(url))
+ {
+ throw new InvalidOperationException(
+ "Beta-Kanal: Server-URL ist noch nicht konfiguriert. " +
+ "Setze die URL unter Einstellungen (Preferences-Key: greg_beta_server_url).");
+ }
+
+ var endpoint = url.TrimEnd('/') + "/api/plugins";
+
+ try
+ {
+ // Use Task.Run to avoid deadlocks from sync-over-async on UI threads
+ var plugins = Task.Run(async () =>
+ {
+ var response = await _http.GetAsync(endpoint).ConfigureAwait(false);
+ response.EnsureSuccessStatusCode();
+
+ var json = await response.Content.ReadAsStringAsync().ConfigureAwait(false);
+
+ return JsonSerializer.Deserialize(json, AppJsonContext.Default.ListPluginPackageInfo);
+ }).GetAwaiter().GetResult();
+
+ return plugins ?? new List();
+ }
+ catch (Exception ex)
+ {
+ throw new InvalidOperationException($"Beta-Kanal: Fehler beim Abrufen der Plugins (URL: {endpoint}). Details: {ex.Message}", ex);
+ }
+ }
+}
From baa9ee8abe2def6a43fb6bf85030183b6ca54f9e Mon Sep 17 00:00:00 2001
From: Marvin <52848568+mleem97@users.noreply.github.com>
Date: Wed, 8 Jul 2026 03:23:40 +0200
Subject: [PATCH 05/21] refactor(steam): expose constants as static properties
---
.../Steam/SteamConstants.cs | 23 +++++++++----------
1 file changed, 11 insertions(+), 12 deletions(-)
diff --git a/src/GregModmanager.Core/Steam/SteamConstants.cs b/src/GregModmanager.Core/Steam/SteamConstants.cs
index 3b01d3a..55fac90 100644
--- a/src/GregModmanager.Core/Steam/SteamConstants.cs
+++ b/src/GregModmanager.Core/Steam/SteamConstants.cs
@@ -1,12 +1,11 @@
-namespace GregModmanager.Steam;
-
-public static class SteamConstants
-{
- /// Steam App ID for Data Center (gregCoreMF).
- public const uint DataCenterAppId = 4170200;
-
- public const int MaxTitleLength = 128;
-
- public const int MaxDescriptionLength = 8000;
-}
-
+namespace GregModmanager.Steam;
+
+public static class SteamConstants
+{
+ /// Steam App ID for Data Center (gregCoreMF).
+ public static uint DataCenterAppId => 4170200;
+
+ public static int MaxTitleLength => 128;
+
+ public static int MaxDescriptionLength => 8000;
+}
From b04e884a436dca1a08206da1bd2bcbbd5c935048 Mon Sep 17 00:00:00 2001
From: Marvin <52848568+mleem97@users.noreply.github.com>
Date: Wed, 8 Jul 2026 03:23:55 +0200
Subject: [PATCH 06/21] refactor(ui): replace dialog optional parameters with
overloads
---
.../Services/DialogService.cs | 48 ++++++++++++++++---
1 file changed, 42 insertions(+), 6 deletions(-)
diff --git a/src/GregModmanager.Avalonia/Services/DialogService.cs b/src/GregModmanager.Avalonia/Services/DialogService.cs
index c5b706e..60c6998 100644
--- a/src/GregModmanager.Avalonia/Services/DialogService.cs
+++ b/src/GregModmanager.Avalonia/Services/DialogService.cs
@@ -9,9 +9,15 @@ namespace GregModmanager.Avalonia.Services;
public interface IDialogService
{
- Task ShowConfirmAsync(string title, string message, string ok = "OK", string cancel = "Cancel");
- Task ShowMessageAsync(string title, string message, string ok = "OK");
- Task ShowPromptAsync(string title, string message, string ok = "OK", string cancel = "Cancel", string initialValue = "");
+ Task ShowConfirmAsync(string title, string message);
+ Task ShowConfirmAsync(string title, string message, string ok);
+ Task ShowConfirmAsync(string title, string message, string ok, string cancel);
+ Task ShowMessageAsync(string title, string message);
+ Task ShowMessageAsync(string title, string message, string ok);
+ Task ShowPromptAsync(string title, string message);
+ Task ShowPromptAsync(string title, string message, string ok);
+ Task ShowPromptAsync(string title, string message, string ok, string cancel);
+ Task ShowPromptAsync(string title, string message, string ok, string cancel, string initialValue);
}
public sealed class DialogService : IDialogService
@@ -23,7 +29,17 @@ public sealed class DialogService : IDialogService
return null;
}
- public async Task ShowConfirmAsync(string title, string message, string ok = "OK", string cancel = "Cancel")
+ public Task ShowConfirmAsync(string title, string message)
+ {
+ return ShowConfirmAsync(title, message, "OK", "Cancel");
+ }
+
+ public Task ShowConfirmAsync(string title, string message, string ok)
+ {
+ return ShowConfirmAsync(title, message, ok, "Cancel");
+ }
+
+ public async Task ShowConfirmAsync(string title, string message, string ok, string cancel)
{
var window = GetTopLevel();
if (window == null) return false;
@@ -67,7 +83,12 @@ public async Task ShowConfirmAsync(string title, string message, string ok
return result == true;
}
- public async Task ShowMessageAsync(string title, string message, string ok = "OK")
+ public Task ShowMessageAsync(string title, string message)
+ {
+ return ShowMessageAsync(title, message, "OK");
+ }
+
+ public async Task ShowMessageAsync(string title, string message, string ok)
{
var window = GetTopLevel();
if (window == null) return;
@@ -106,7 +127,22 @@ public async Task ShowMessageAsync(string title, string message, string ok = "OK
await dialog.ShowDialog(window);
}
- public async Task ShowPromptAsync(string title, string message, string ok = "OK", string cancel = "Cancel", string initialValue = "")
+ public Task ShowPromptAsync(string title, string message)
+ {
+ return ShowPromptAsync(title, message, "OK", "Cancel", string.Empty);
+ }
+
+ public Task ShowPromptAsync(string title, string message, string ok)
+ {
+ return ShowPromptAsync(title, message, ok, "Cancel", string.Empty);
+ }
+
+ public Task ShowPromptAsync(string title, string message, string ok, string cancel)
+ {
+ return ShowPromptAsync(title, message, ok, cancel, string.Empty);
+ }
+
+ public async Task ShowPromptAsync(string title, string message, string ok, string cancel, string initialValue)
{
var window = GetTopLevel();
if (window == null) return null;
From 2c5b7032597ca4da5f111a8b02c73a30d978e45b Mon Sep 17 00:00:00 2001
From: Marvin <52848568+mleem97@users.noreply.github.com>
Date: Wed, 8 Jul 2026 03:24:02 +0200
Subject: [PATCH 07/21] refactor(core): make cli entrypoint static
---
src/GregModmanager.Core/Program.cs | 32 +++++++++++++++---------------
1 file changed, 16 insertions(+), 16 deletions(-)
diff --git a/src/GregModmanager.Core/Program.cs b/src/GregModmanager.Core/Program.cs
index 3c21042..5ba602b 100644
--- a/src/GregModmanager.Core/Program.cs
+++ b/src/GregModmanager.Core/Program.cs
@@ -1,16 +1,16 @@
-using System;
-
-namespace GregModmanager;
-
-public class Program
-{
- public static void Main(string[] args)
- {
- Console.WriteLine("gregModmanager Core CLI (Linux)");
- Console.WriteLine("Version: 1.5.0");
- Console.WriteLine("Ecosystem: gregFramework v1.0.0.30-pre");
-
- // This is a stub for the Linux-compatible background logic
- // GUI components are excluded in this target.
- }
-}
+using System;
+
+namespace GregModmanager;
+
+public static class Program
+{
+ public static void Main(string[] args)
+ {
+ Console.WriteLine("gregModmanager Core CLI (Linux)");
+ Console.WriteLine("Version: 1.5.0");
+ Console.WriteLine("Ecosystem: gregFramework v1.0.0.30-pre");
+
+ // This is a stub for the Linux-compatible background logic
+ // GUI components are excluded in this target.
+ }
+}
From a7afc9cb483fc8d7be2e230e9786e6c614df9410 Mon Sep 17 00:00:00 2001
From: Marvin <52848568+mleem97@users.noreply.github.com>
Date: Wed, 8 Jul 2026 03:24:08 +0200
Subject: [PATCH 08/21] fix(helper): avoid IP-looking fallback version
---
.../SubDirectoryFixerBootstrap.cs | 26 +++++++++----------
1 file changed, 13 insertions(+), 13 deletions(-)
diff --git a/src/SubDirectoryFixer/SubDirectoryFixerBootstrap.cs b/src/SubDirectoryFixer/SubDirectoryFixerBootstrap.cs
index 5c16bd8..113fd14 100644
--- a/src/SubDirectoryFixer/SubDirectoryFixerBootstrap.cs
+++ b/src/SubDirectoryFixer/SubDirectoryFixerBootstrap.cs
@@ -1,13 +1,13 @@
-using System.Reflection;
-
-namespace SubDirectoryFixer;
-
-public static class SubDirectoryFixerBootstrap
-{
- public static string Version => Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "1.0.0.0";
-
- public static string Describe()
- {
- return "SubDirectoryFixer helper assembly for gregModmanager deployment.";
- }
-}
\ No newline at end of file
+using System.Reflection;
+
+namespace SubDirectoryFixer;
+
+public static class SubDirectoryFixerBootstrap
+{
+ public static string Version => Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
+
+ public static string Describe()
+ {
+ return "SubDirectoryFixer helper assembly for gregModmanager deployment.";
+ }
+}
From b8180c8a20bf3270ed86a8776c5cbfc9fca61c63 Mon Sep 17 00:00:00 2001
From: Marvin <52848568+mleem97@users.noreply.github.com>
Date: Wed, 8 Jul 2026 03:25:04 +0200
Subject: [PATCH 09/21] fix(core): harden repro bundle process invocation
---
.../Services/ReproBundleService.cs | 353 ++++++++++--------
1 file changed, 188 insertions(+), 165 deletions(-)
diff --git a/src/GregModmanager.Core/Services/ReproBundleService.cs b/src/GregModmanager.Core/Services/ReproBundleService.cs
index b481f3a..e4dec79 100644
--- a/src/GregModmanager.Core/Services/ReproBundleService.cs
+++ b/src/GregModmanager.Core/Services/ReproBundleService.cs
@@ -1,165 +1,188 @@
-using System.Diagnostics;
-using System.IO.Compression;
-using System.Reflection;
-using System.Text;
-
-namespace GregModmanager.Services;
-
-public sealed class ReproBundleService
-{
- private const string AppFolderName = "gregModmanager";
-
- public Task CreateBundleAsync(CancellationToken cancellationToken = default)
- {
- var now = DateTime.Now;
- var root = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
- AppFolderName,
- "repro");
- Directory.CreateDirectory(root);
-
- var stamp = now.ToString("yyyyMMdd-HHmmss");
- var stagingDir = Path.Combine(root, $"bundle-{stamp}");
- var zipPath = Path.Combine(root, $"repro-bundle-{stamp}.zip");
-
- if (Directory.Exists(stagingDir))
- {
- Directory.Delete(stagingDir, true);
- }
- Directory.CreateDirectory(stagingDir);
-
- WriteSummary(stagingDir, now);
- CopyDiagnostics(stagingDir);
- WriteRecentEventLog(stagingDir);
-
- if (File.Exists(zipPath))
- {
- File.Delete(zipPath);
- }
-
- ZipFile.CreateFromDirectory(stagingDir, zipPath, CompressionLevel.Optimal, includeBaseDirectory: false);
- Directory.Delete(stagingDir, true);
-
- cancellationToken.ThrowIfCancellationRequested();
- return Task.FromResult(zipPath);
- }
-
- private static void WriteSummary(string stagingDir, DateTime createdAt)
- {
- var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
- var summary = new StringBuilder();
- summary.AppendLine("gregModmanager - Repro Bundle");
- summary.AppendLine($"CreatedAt: {createdAt:O}");
- summary.AppendLine($"AppVersion: {version}");
- summary.AppendLine($"OSVersion: {Environment.OSVersion}");
- summary.AppendLine($"Framework: {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}");
- summary.AppendLine($"Architecture: {System.Runtime.InteropServices.RuntimeInformation.OSArchitecture}");
- summary.AppendLine($"ProcessArchitecture: {System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture}");
- summary.AppendLine($"BaseDirectory: {AppContext.BaseDirectory}");
- summary.AppendLine($"CurrentDirectory: {Environment.CurrentDirectory}");
- summary.AppendLine($"MachineName: {Environment.MachineName}");
- summary.AppendLine($"UserName: {Environment.UserName}");
-
- File.WriteAllText(Path.Combine(stagingDir, "environment.txt"), summary.ToString());
- }
-
- private static void CopyDiagnostics(string stagingDir)
- {
- var userLogs = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), AppFolderName, "logs");
- CopyDirectorySafe(userLogs, Path.Combine(stagingDir, "logs"));
-
- var userDumps = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), AppFolderName, "dumps");
- CopyDirectorySafe(userDumps, Path.Combine(stagingDir, "dumps-user"));
-
- var machineDumps = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), AppFolderName, "dumps");
- CopyDirectorySafe(machineDumps, Path.Combine(stagingDir, "dumps-machine"));
- }
-
- private static void CopyDirectorySafe(string sourceDir, string targetDir)
- {
- try
- {
- if (!Directory.Exists(sourceDir))
- {
- return;
- }
-
- Directory.CreateDirectory(targetDir);
- foreach (var file in Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories))
- {
- try
- {
- var relativePath = Path.GetRelativePath(sourceDir, file);
- var dest = Path.Combine(targetDir, relativePath);
- var destDir = Path.GetDirectoryName(dest);
- if (!string.IsNullOrWhiteSpace(destDir))
- {
- Directory.CreateDirectory(destDir);
- }
- File.Copy(file, dest, true);
- }
- catch
- {
- // skip unreadable files
- }
- }
- }
- catch
- {
- // ignore copy failures in diagnostics collection
- }
- }
-
- private static void WriteRecentEventLog(string stagingDir)
- {
- if (!OperatingSystem.IsWindows())
- {
- return;
- }
-
- var outputPath = Path.Combine(stagingDir, "eventlog.txt");
- var psCommand = "$events = Get-WinEvent -FilterHashtable @{LogName='Application'; StartTime=(Get-Date).AddDays(-2)} | Where-Object { ($_.ProviderName -in @('Application Error','.NET Runtime','Windows Error Reporting')) -and $_.Message -match 'GregModmanager' } | Select-Object -First 200 TimeCreated,ProviderName,Id,LevelDisplayName,Message; if (-not $events) { 'No matching events found.' } else { $events | Format-List | Out-String -Width 240 }";
-
- try
- {
- var startInfo = new ProcessStartInfo
- {
- FileName = "powershell.exe",
- Arguments = $"-NoProfile -ExecutionPolicy Bypass -Command \"{psCommand}\"",
- UseShellExecute = false,
- RedirectStandardOutput = true,
- RedirectStandardError = true,
- CreateNoWindow = true
- };
-
- using var proc = Process.Start(startInfo);
- if (proc is null)
- {
- File.WriteAllText(outputPath, "Could not start powershell.exe for event log export.");
- return;
- }
-
- var output = proc.StandardOutput.ReadToEnd();
- var error = proc.StandardError.ReadToEnd();
- proc.WaitForExit(7000);
-
- var sb = new StringBuilder();
- sb.AppendLine("Recent Application log events for GregModmanager (last 48h)");
- sb.AppendLine();
- sb.AppendLine(output);
-
- if (!string.IsNullOrWhiteSpace(error))
- {
- sb.AppendLine("--- STDERR ---");
- sb.AppendLine(error);
- }
-
- File.WriteAllText(outputPath, sb.ToString());
- }
- catch (Exception ex)
- {
- File.WriteAllText(outputPath, $"Could not read Event Log: {ex.Message}");
- }
- }
-}
-
+using System.Diagnostics;
+using System.IO.Compression;
+using System.Reflection;
+using System.Text;
+
+namespace GregModmanager.Services;
+
+public sealed class ReproBundleService
+{
+ private const string AppFolderName = "gregModmanager";
+
+ public Task CreateBundleAsync()
+ {
+ return CreateBundleAsync(CancellationToken.None);
+ }
+
+ public Task CreateBundleAsync(CancellationToken cancellationToken)
+ {
+ var now = DateTime.Now;
+ var root = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ AppFolderName,
+ "repro");
+ Directory.CreateDirectory(root);
+
+ var stamp = now.ToString("yyyyMMdd-HHmmss");
+ var stagingDir = Path.Combine(root, $"bundle-{stamp}");
+ var zipPath = Path.Combine(root, $"repro-bundle-{stamp}.zip");
+
+ if (Directory.Exists(stagingDir))
+ {
+ Directory.Delete(stagingDir, true);
+ }
+ Directory.CreateDirectory(stagingDir);
+
+ WriteSummary(stagingDir, now);
+ CopyDiagnostics(stagingDir);
+ WriteRecentEventLog(stagingDir);
+
+ if (File.Exists(zipPath))
+ {
+ File.Delete(zipPath);
+ }
+
+ ZipFile.CreateFromDirectory(stagingDir, zipPath, CompressionLevel.Optimal, includeBaseDirectory: false);
+ Directory.Delete(stagingDir, true);
+
+ cancellationToken.ThrowIfCancellationRequested();
+ return Task.FromResult(zipPath);
+ }
+
+ private static void WriteSummary(string stagingDir, DateTime createdAt)
+ {
+ var version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "unknown";
+ var summary = new StringBuilder();
+ summary.AppendLine("gregModmanager - Repro Bundle");
+ summary.AppendLine($"CreatedAt: {createdAt:O}");
+ summary.AppendLine($"AppVersion: {version}");
+ summary.AppendLine($"OSVersion: {Environment.OSVersion}");
+ summary.AppendLine($"Framework: {System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription}");
+ summary.AppendLine($"Architecture: {System.Runtime.InteropServices.RuntimeInformation.OSArchitecture}");
+ summary.AppendLine($"ProcessArchitecture: {System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture}");
+ summary.AppendLine($"BaseDirectory: {AppContext.BaseDirectory}");
+ summary.AppendLine($"CurrentDirectory: {Environment.CurrentDirectory}");
+ summary.AppendLine($"MachineName: {Environment.MachineName}");
+ summary.AppendLine($"UserName: {Environment.UserName}");
+
+ File.WriteAllText(Path.Combine(stagingDir, "environment.txt"), summary.ToString());
+ }
+
+ private static void CopyDiagnostics(string stagingDir)
+ {
+ var userLogs = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), AppFolderName, "logs");
+ CopyDirectorySafe(userLogs, Path.Combine(stagingDir, "logs"));
+
+ var userDumps = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), AppFolderName, "dumps");
+ CopyDirectorySafe(userDumps, Path.Combine(stagingDir, "dumps-user"));
+
+ var machineDumps = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), AppFolderName, "dumps");
+ CopyDirectorySafe(machineDumps, Path.Combine(stagingDir, "dumps-machine"));
+ }
+
+ private static void CopyDirectorySafe(string sourceDir, string targetDir)
+ {
+ try
+ {
+ if (!Directory.Exists(sourceDir))
+ {
+ return;
+ }
+
+ Directory.CreateDirectory(targetDir);
+ foreach (var file in Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories))
+ {
+ CopyDiagnosticFile(sourceDir, targetDir, file);
+ }
+ }
+ catch
+ {
+ // ignore copy failures in diagnostics collection
+ }
+ }
+
+ private static void CopyDiagnosticFile(string sourceDir, string targetDir, string file)
+ {
+ try
+ {
+ var relativePath = Path.GetRelativePath(sourceDir, file);
+ var dest = Path.Combine(targetDir, relativePath);
+ var destDir = Path.GetDirectoryName(dest);
+ if (!string.IsNullOrWhiteSpace(destDir))
+ {
+ Directory.CreateDirectory(destDir);
+ }
+ File.Copy(file, dest, true);
+ }
+ catch
+ {
+ // skip unreadable files
+ }
+ }
+
+ private static void WriteRecentEventLog(string stagingDir)
+ {
+ if (!OperatingSystem.IsWindows())
+ {
+ return;
+ }
+
+ var outputPath = Path.Combine(stagingDir, "eventlog.txt");
+ const string psCommand = "$events = Get-WinEvent -FilterHashtable @{LogName='Application'; StartTime=(Get-Date).AddDays(-2)} | Where-Object { ($_.ProviderName -in @('Application Error','.NET Runtime','Windows Error Reporting')) -and $_.Message -match 'GregModmanager' } | Select-Object -First 200 TimeCreated,ProviderName,Id,LevelDisplayName,Message; if (-not $events) { 'No matching events found.' } else { $events | Format-List | Out-String -Width 240 }";
+
+ try
+ {
+ using var proc = StartPowerShell(psCommand);
+ if (proc is null)
+ {
+ File.WriteAllText(outputPath, "Could not start powershell.exe for event log export.");
+ return;
+ }
+
+ var output = proc.StandardOutput.ReadToEnd();
+ var error = proc.StandardError.ReadToEnd();
+ proc.WaitForExit(7000);
+
+ WriteEventLogOutput(outputPath, output, error);
+ }
+ catch (Exception ex)
+ {
+ File.WriteAllText(outputPath, $"Could not read Event Log: {ex.Message}");
+ }
+ }
+
+ private static Process? StartPowerShell(string command)
+ {
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = "powershell.exe",
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true
+ };
+ startInfo.ArgumentList.Add("-NoProfile");
+ startInfo.ArgumentList.Add("-ExecutionPolicy");
+ startInfo.ArgumentList.Add("Bypass");
+ startInfo.ArgumentList.Add("-Command");
+ startInfo.ArgumentList.Add(command);
+
+ return Process.Start(startInfo);
+ }
+
+ private static void WriteEventLogOutput(string outputPath, string output, string error)
+ {
+ var sb = new StringBuilder();
+ sb.AppendLine("Recent Application log events for GregModmanager (last 48h)");
+ sb.AppendLine();
+ sb.AppendLine(output);
+
+ if (!string.IsNullOrWhiteSpace(error))
+ {
+ sb.AppendLine("--- STDERR ---");
+ sb.AppendLine(error);
+ }
+
+ File.WriteAllText(outputPath, sb.ToString());
+ }
+}
From 07fe4d16acf89c74d2aa06696a3216b6eff78ebb Mon Sep 17 00:00:00 2001
From: Marvin <52848568+mleem97@users.noreply.github.com>
Date: Wed, 8 Jul 2026 03:25:38 +0200
Subject: [PATCH 10/21] refactor(core): replace collection optional parameters
with overloads
---
.../Services/ModCollectionService.cs | 241 ++++++++++++------
1 file changed, 168 insertions(+), 73 deletions(-)
diff --git a/src/GregModmanager.Core/Services/ModCollectionService.cs b/src/GregModmanager.Core/Services/ModCollectionService.cs
index f71a45a..ec6d5a4 100644
--- a/src/GregModmanager.Core/Services/ModCollectionService.cs
+++ b/src/GregModmanager.Core/Services/ModCollectionService.cs
@@ -38,18 +38,29 @@ public IReadOnlyList GetCollections()
}
}
- public ModCollectionDefinition EnsureCollection(string name, string? description = null, ModCollectionSourceKind sourceKind = ModCollectionSourceKind.Local, string? sourceName = null)
+ public ModCollectionDefinition EnsureCollection(string name)
+ {
+ return EnsureCollection(name, description: null, ModCollectionSourceKind.Local, sourceName: null);
+ }
+
+ public ModCollectionDefinition EnsureCollection(string name, string? description)
+ {
+ return EnsureCollection(name, description, ModCollectionSourceKind.Local, sourceName: null);
+ }
+
+ public ModCollectionDefinition EnsureCollection(string name, string? description, ModCollectionSourceKind sourceKind)
+ {
+ return EnsureCollection(name, description, sourceKind, sourceName: null);
+ }
+
+ public ModCollectionDefinition EnsureCollection(string name, string? description, ModCollectionSourceKind sourceKind, string? sourceName)
{
lock (_gate)
{
var existing = _catalog.Collections.FirstOrDefault(x => string.Equals(x.Name, name, StringComparison.OrdinalIgnoreCase));
if (existing is not null)
{
- if (!string.IsNullOrWhiteSpace(description)) existing.Description = description;
- existing.SourceKind = sourceKind;
- existing.SourceName = sourceName ?? existing.SourceName;
- existing.UpdatedUtc = DateTimeOffset.UtcNow;
- SaveCatalog();
+ UpdateExistingCollection(existing, description, sourceKind, sourceName);
return existing;
}
@@ -66,14 +77,34 @@ public ModCollectionDefinition EnsureCollection(string name, string? description
}
}
- public ModCollectionDefinition EnsureCollectionForItem(string collectionName, ulong publishedFileId, string title, string sourceName, string modType = "PlacableObject")
+ public ModCollectionDefinition EnsureCollectionForItem(string collectionName, ulong publishedFileId, string title, string sourceName)
+ {
+ return EnsureCollectionForItem(collectionName, publishedFileId, title, sourceName, "PlacableObject");
+ }
+
+ public ModCollectionDefinition EnsureCollectionForItem(string collectionName, ulong publishedFileId, string title, string sourceName, string modType)
{
- var collection = EnsureCollection(collectionName, sourceName: sourceName);
+ var collection = EnsureCollection(collectionName, description: null, ModCollectionSourceKind.Local, sourceName);
AddItem(collection.Id, publishedFileId, title, sourceName, modType);
return collection;
}
- public bool AddItem(Guid collectionId, ulong publishedFileId, string title, string sourceName, string modType = "PlacableObject", IEnumerable? workshopDependencyIds = null, string? notes = null)
+ public bool AddItem(Guid collectionId, ulong publishedFileId, string title, string sourceName)
+ {
+ return AddItem(collectionId, publishedFileId, title, sourceName, "PlacableObject", workshopDependencyIds: null, notes: null);
+ }
+
+ public bool AddItem(Guid collectionId, ulong publishedFileId, string title, string sourceName, string modType)
+ {
+ return AddItem(collectionId, publishedFileId, title, sourceName, modType, workshopDependencyIds: null, notes: null);
+ }
+
+ public bool AddItem(Guid collectionId, ulong publishedFileId, string title, string sourceName, string modType, IEnumerable? workshopDependencyIds)
+ {
+ return AddItem(collectionId, publishedFileId, title, sourceName, modType, workshopDependencyIds, notes: null);
+ }
+
+ public bool AddItem(Guid collectionId, ulong publishedFileId, string title, string sourceName, string modType, IEnumerable? workshopDependencyIds, string? notes)
{
lock (_gate)
{
@@ -81,15 +112,7 @@ public bool AddItem(Guid collectionId, ulong publishedFileId, string title, stri
if (collection is null) return false;
if (collection.Items.Any(x => x.PublishedFileId == publishedFileId)) return true;
- collection.Items.Add(new ModCollectionEntry
- {
- PublishedFileId = publishedFileId,
- Title = title.Trim(),
- SourceName = sourceName.Trim(),
- ModType = string.IsNullOrWhiteSpace(modType) ? "PlacableObject" : modType.Trim(),
- WorkshopDependencyIds = workshopDependencyIds?.Distinct().ToList() ?? new List(),
- Notes = notes,
- });
+ collection.Items.Add(CreateEntry(publishedFileId, title, sourceName, modType, workshopDependencyIds, notes));
collection.UpdatedUtc = DateTimeOffset.UtcNow;
SaveCatalog();
return true;
@@ -135,94 +158,156 @@ public bool DeleteCollection(Guid id)
}
}
+ public Task SyncCollectionAsync(Guid collectionId, WorkshopDownloadService downloader, ModsFolderSyncService sync, string gameRoot)
+ {
+ return SyncCollectionAsync(collectionId, downloader, sync, gameRoot, log: null, CancellationToken.None);
+ }
+
+ public Task SyncCollectionAsync(Guid collectionId, WorkshopDownloadService downloader, ModsFolderSyncService sync, string gameRoot, IProgress? log)
+ {
+ return SyncCollectionAsync(collectionId, downloader, sync, gameRoot, log, CancellationToken.None);
+ }
+
public async Task SyncCollectionAsync(
Guid collectionId,
WorkshopDownloadService downloader,
ModsFolderSyncService sync,
string gameRoot,
- IProgress? log = null,
- CancellationToken ct = default)
+ IProgress? log,
+ CancellationToken ct)
{
- ModCollectionDefinition? collection;
- lock (_gate)
+ var collection = GetCollectionForSync(collectionId, log);
+ if (collection is null || !IsGameRootConfigured(gameRoot, log))
{
- collection = _catalog.Collections.FirstOrDefault(x => x.Id == collectionId);
+ return false;
}
- if (collection is null)
+ var context = new CollectionSyncContext(collection, downloader, sync, gameRoot, log, ct);
+ var result = await ProcessCollectionQueueAsync(context).ConfigureAwait(false);
+ TrackSyncResult(collectionId, collection, result);
+ return result.AnySucceeded;
+ }
+
+ private void UpdateExistingCollection(ModCollectionDefinition existing, string? description, ModCollectionSourceKind sourceKind, string? sourceName)
+ {
+ if (!string.IsNullOrWhiteSpace(description)) existing.Description = description;
+ existing.SourceKind = sourceKind;
+ existing.SourceName = sourceName ?? existing.SourceName;
+ existing.UpdatedUtc = DateTimeOffset.UtcNow;
+ SaveCatalog();
+ }
+
+ private static ModCollectionEntry CreateEntry(ulong publishedFileId, string title, string sourceName, string modType, IEnumerable? workshopDependencyIds, string? notes)
+ {
+ return new ModCollectionEntry
{
- log?.Report("Collection not found.");
- return false;
- }
+ PublishedFileId = publishedFileId,
+ Title = title.Trim(),
+ SourceName = sourceName.Trim(),
+ ModType = string.IsNullOrWhiteSpace(modType) ? "PlacableObject" : modType.Trim(),
+ WorkshopDependencyIds = workshopDependencyIds?.Distinct().ToList() ?? new List(),
+ Notes = notes,
+ };
+ }
- if (string.IsNullOrWhiteSpace(gameRoot) || !Directory.Exists(gameRoot))
+ private ModCollectionDefinition? GetCollectionForSync(Guid collectionId, IProgress? log)
+ {
+ lock (_gate)
{
- log?.Report("Game root not configured.");
- return false;
+ var collection = _catalog.Collections.FirstOrDefault(x => x.Id == collectionId);
+ if (collection is not null) return collection;
}
+ log?.Report("Collection not found.");
+ return null;
+ }
+
+ private static bool IsGameRootConfigured(string gameRoot, IProgress? log)
+ {
+ if (!string.IsNullOrWhiteSpace(gameRoot) && Directory.Exists(gameRoot)) return true;
+
+ log?.Report("Game root not configured.");
+ return false;
+ }
+
+ private static async Task ProcessCollectionQueueAsync(CollectionSyncContext context)
+ {
var seen = new HashSet();
- var queue = new Queue(collection.Items);
+ var queue = new Queue(context.Collection.Items);
+ var sw = System.Diagnostics.Stopwatch.StartNew();
var anySucceeded = false;
- var sw = System.Diagnostics.Stopwatch.StartNew();
while (queue.Count > 0)
{
- ct.ThrowIfCancellationRequested();
- var entry = queue.Dequeue();
- if (!seen.Add(entry.PublishedFileId))
- {
- continue;
- }
+ context.CancellationToken.ThrowIfCancellationRequested();
+ anySucceeded |= await ProcessCollectionEntryAsync(context, queue, seen).ConfigureAwait(false);
+ }
- log?.Report($"Downloading {entry.Title} ({entry.PublishedFileId})…");
- var download = await downloader.DownloadItemAsync(entry.PublishedFileId, null, log, ct).ConfigureAwait(false);
- if (!download.Success || string.IsNullOrWhiteSpace(download.LocalDirectory))
- {
- log?.Report(download.ErrorMessage ?? $"Download failed for {entry.PublishedFileId}.");
- continue;
- }
+ sw.Stop();
+ return new CollectionSyncResult(anySucceeded, seen.Count, sw.ElapsedMilliseconds);
+ }
- var synced = sync.SyncItem(entry.PublishedFileId, download.LocalDirectory, gameRoot);
- if (synced.Success)
- {
- anySucceeded = true;
- log?.Report($"Synced {entry.PublishedFileId} → {synced.DestinationPath}");
- }
- else
- {
- log?.Report(synced.ErrorMessage ?? $"Sync failed for {entry.PublishedFileId}.");
- }
+ private static async Task ProcessCollectionEntryAsync(CollectionSyncContext context, Queue queue, HashSet seen)
+ {
+ var entry = queue.Dequeue();
+ if (!seen.Add(entry.PublishedFileId)) return false;
- foreach (var dependencyId in entry.WorkshopDependencyIds)
+ context.Log?.Report($"Downloading {entry.Title} ({entry.PublishedFileId})…");
+ var download = await context.Downloader.DownloadItemAsync(entry.PublishedFileId, progress: null, context.Log, context.CancellationToken).ConfigureAwait(false);
+ if (!download.Success || string.IsNullOrWhiteSpace(download.LocalDirectory))
+ {
+ context.Log?.Report(download.ErrorMessage ?? $"Download failed for {entry.PublishedFileId}.");
+ return false;
+ }
+
+ var succeeded = SyncDownloadedEntry(context, entry, download.LocalDirectory);
+ EnqueueDependencies(queue, seen, entry);
+ return succeeded;
+ }
+
+ private static bool SyncDownloadedEntry(CollectionSyncContext context, ModCollectionEntry entry, string localDirectory)
+ {
+ var synced = context.Sync.SyncItem(entry.PublishedFileId, localDirectory, context.GameRoot);
+ if (synced.Success)
+ {
+ context.Log?.Report($"Synced {entry.PublishedFileId} → {synced.DestinationPath}");
+ return true;
+ }
+
+ context.Log?.Report(synced.ErrorMessage ?? $"Sync failed for {entry.PublishedFileId}.");
+ return false;
+ }
+
+ private static void EnqueueDependencies(Queue queue, HashSet seen, ModCollectionEntry entry)
+ {
+ foreach (var dependencyId in entry.WorkshopDependencyIds)
+ {
+ if (!seen.Contains(dependencyId))
{
- if (!seen.Contains(dependencyId))
+ queue.Enqueue(new ModCollectionEntry
{
- queue.Enqueue(new ModCollectionEntry
- {
- PublishedFileId = dependencyId,
- Title = $"Dependency {dependencyId}",
- SourceName = entry.SourceName,
- ModType = "Userlib",
- });
- }
+ PublishedFileId = dependencyId,
+ Title = $"Dependency {dependencyId}",
+ SourceName = entry.SourceName,
+ ModType = "Userlib",
+ });
}
}
- sw.Stop();
+ }
+ private void TrackSyncResult(Guid collectionId, ModCollectionDefinition collection, CollectionSyncResult result)
+ {
_ = _telemetry.TrackEventAsync("sync_collection", new SyncCollectionEvent
{
collectionId = collectionId,
collectionName = collection.Name,
- success = anySucceeded,
- itemCount = seen.Count,
- durationMs = sw.ElapsedMilliseconds
+ success = result.AnySucceeded,
+ itemCount = result.SeenCount,
+ durationMs = result.ElapsedMilliseconds
}, new Dictionary
{
- { "outcome", anySucceeded ? "success" : "failed" }
+ { "outcome", result.AnySucceeded ? "success" : "failed" }
});
-
- return anySucceeded;
}
private CollectionCatalog LoadCatalog()
@@ -247,4 +332,14 @@ private void SaveCatalog()
{
File.WriteAllText(_storagePath, JsonSerializer.Serialize(_catalog, AppJsonContext.Default.CollectionCatalog));
}
-}
\ No newline at end of file
+
+ private sealed record CollectionSyncContext(
+ ModCollectionDefinition Collection,
+ WorkshopDownloadService Downloader,
+ ModsFolderSyncService Sync,
+ string GameRoot,
+ IProgress? Log,
+ CancellationToken CancellationToken);
+
+ private readonly record struct CollectionSyncResult(bool AnySucceeded, int SeenCount, long ElapsedMilliseconds);
+}
From b233c642c753605a16f76bb17cff606d31b2fb82 Mon Sep 17 00:00:00 2001
From: Marvin <52848568+mleem97@users.noreply.github.com>
Date: Wed, 8 Jul 2026 03:25:55 +0200
Subject: [PATCH 11/21] refactor(core): replace download optional parameters
with overloads
---
.../Services/WorkshopDownloadService.cs | 200 ++++++++++--------
1 file changed, 112 insertions(+), 88 deletions(-)
diff --git a/src/GregModmanager.Core/Services/WorkshopDownloadService.cs b/src/GregModmanager.Core/Services/WorkshopDownloadService.cs
index 6a6de3a..b9e0620 100644
--- a/src/GregModmanager.Core/Services/WorkshopDownloadService.cs
+++ b/src/GregModmanager.Core/Services/WorkshopDownloadService.cs
@@ -1,88 +1,112 @@
-using Steamworks;
-using Steamworks.Data;
-using Steamworks.Ugc;
-
-namespace GregModmanager.Services;
-
-///
-/// Downloads Steam Workshop items by their published file ID and reports progress.
-/// Wraps the Facepunch API.
-///
-public sealed class WorkshopDownloadService
-{
- private readonly SteamWorkshopService _steam;
-
- public WorkshopDownloadService(SteamWorkshopService steam)
- {
- _steam = steam;
- }
-
- ///
- /// Downloads a single Workshop item. Returns the local install directory on success, null on failure.
- ///
- public async Task DownloadItemAsync(
- ulong publishedFileId,
- IProgress? progress = null,
- IProgress? log = null,
- CancellationToken ct = default)
- {
- if (!_steam.EnsureInitialized(log))
- return DownloadResult.Fail("Steam is not available.");
-
- ct.ThrowIfCancellationRequested();
-
- var item = await Item.GetAsync((PublishedFileId)publishedFileId).ConfigureAwait(false);
- if (!item.HasValue)
- return DownloadResult.Fail($"Item {publishedFileId} not found on Steam.");
-
- var ugc = item.Value;
- log?.Report($"Downloading {ugc.Title ?? publishedFileId.ToString()}…");
-
- try
- {
- await ugc.DownloadAsync(
- p => progress?.Report(p),
- 60,
- ct).ConfigureAwait(false);
- }
- catch (Exception ex)
- {
- return DownloadResult.Fail($"Download failed: {ex.Message}");
- }
-
- var dir = ugc.Directory;
- if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir))
- return DownloadResult.Fail("Steam did not provide a local folder after download.");
-
- log?.Report($"Downloaded to {dir}");
- return DownloadResult.Ok(dir, ugc.Title ?? publishedFileId.ToString());
- }
-
- ///
- /// Downloads multiple items sequentially, reporting aggregate progress.
- ///
- public async Task> DownloadItemsAsync(
- IReadOnlyList ids,
- IProgress? log = null,
- CancellationToken ct = default)
- {
- var results = new List(ids.Count);
- for (var i = 0; i < ids.Count; i++)
- {
- ct.ThrowIfCancellationRequested();
- var itemProgress = new Progress(p =>
- log?.Report($"[{i + 1}/{ids.Count}] {p:P0}"));
- var result = await DownloadItemAsync(ids[i], itemProgress, log, ct).ConfigureAwait(false);
- results.Add(result);
- }
-
- return results;
- }
-}
-
-public readonly record struct DownloadResult(bool Success, string? LocalDirectory, string Title, string? ErrorMessage)
-{
- public static DownloadResult Ok(string directory, string title) => new(true, directory, title, null);
- public static DownloadResult Fail(string error) => new(false, null, "", error);
-}
-
+using Steamworks;
+using Steamworks.Data;
+using Steamworks.Ugc;
+
+namespace GregModmanager.Services;
+
+///
+/// Downloads Steam Workshop items by their published file ID and reports progress.
+/// Wraps the Facepunch API.
+///
+public sealed class WorkshopDownloadService
+{
+ private readonly SteamWorkshopService _steam;
+
+ public WorkshopDownloadService(SteamWorkshopService steam)
+ {
+ _steam = steam;
+ }
+
+ ///
+ /// Downloads a single Workshop item. Returns the local install directory on success, null on failure.
+ ///
+ public Task DownloadItemAsync(ulong publishedFileId)
+ {
+ return DownloadItemAsync(publishedFileId, progress: null, log: null, CancellationToken.None);
+ }
+
+ public Task DownloadItemAsync(ulong publishedFileId, IProgress? progress)
+ {
+ return DownloadItemAsync(publishedFileId, progress, log: null, CancellationToken.None);
+ }
+
+ public Task DownloadItemAsync(ulong publishedFileId, IProgress? progress, IProgress? log)
+ {
+ return DownloadItemAsync(publishedFileId, progress, log, CancellationToken.None);
+ }
+
+ public async Task DownloadItemAsync(
+ ulong publishedFileId,
+ IProgress? progress,
+ IProgress? log,
+ CancellationToken ct)
+ {
+ if (!_steam.EnsureInitialized(log))
+ return DownloadResult.Fail("Steam is not available.");
+
+ ct.ThrowIfCancellationRequested();
+
+ var item = await Item.GetAsync((PublishedFileId)publishedFileId).ConfigureAwait(false);
+ if (!item.HasValue)
+ return DownloadResult.Fail($"Item {publishedFileId} not found on Steam.");
+
+ var ugc = item.Value;
+ log?.Report($"Downloading {ugc.Title ?? publishedFileId.ToString()}…");
+
+ try
+ {
+ await ugc.DownloadAsync(
+ p => progress?.Report(p),
+ 60,
+ ct).ConfigureAwait(false);
+ }
+ catch (Exception ex)
+ {
+ return DownloadResult.Fail($"Download failed: {ex.Message}");
+ }
+
+ var dir = ugc.Directory;
+ if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir))
+ return DownloadResult.Fail("Steam did not provide a local folder after download.");
+
+ log?.Report($"Downloaded to {dir}");
+ return DownloadResult.Ok(dir, ugc.Title ?? publishedFileId.ToString());
+ }
+
+ ///
+ /// Downloads multiple items sequentially, reporting aggregate progress.
+ ///
+ public Task> DownloadItemsAsync(IReadOnlyList ids)
+ {
+ return DownloadItemsAsync(ids, log: null, CancellationToken.None);
+ }
+
+ public Task> DownloadItemsAsync(IReadOnlyList ids, IProgress? log)
+ {
+ return DownloadItemsAsync(ids, log, CancellationToken.None);
+ }
+
+ public async Task> DownloadItemsAsync(
+ IReadOnlyList ids,
+ IProgress? log,
+ CancellationToken ct)
+ {
+ var results = new List(ids.Count);
+ for (var i = 0; i < ids.Count; i++)
+ {
+ ct.ThrowIfCancellationRequested();
+ var itemProgress = new Progress(p =>
+ log?.Report($"[{i + 1}/{ids.Count}] {p:P0}"));
+ var result = await DownloadItemAsync(ids[i], itemProgress, log, ct).ConfigureAwait(false);
+ results.Add(result);
+ }
+
+ return results;
+ }
+}
+
+public readonly record struct DownloadResult(bool Success, string? LocalDirectory, string Title, string? ErrorMessage)
+{
+ public static DownloadResult Ok(string directory, string title) => new(true, directory, title, null);
+ public static DownloadResult Fail(string error) => new(false, null, "", error);
+}
From 4aa815b6c5664ded6435512bf3742950ba1aea7a Mon Sep 17 00:00:00 2001
From: Marvin <52848568+mleem97@users.noreply.github.com>
Date: Wed, 8 Jul 2026 03:26:20 +0200
Subject: [PATCH 12/21] refactor(core): replace sync optional parameter with
overload
---
.../Services/ModsFolderSyncService.cs | 348 +++++++++---------
1 file changed, 176 insertions(+), 172 deletions(-)
diff --git a/src/GregModmanager.Core/Services/ModsFolderSyncService.cs b/src/GregModmanager.Core/Services/ModsFolderSyncService.cs
index 5be0d4b..8117777 100644
--- a/src/GregModmanager.Core/Services/ModsFolderSyncService.cs
+++ b/src/GregModmanager.Core/Services/ModsFolderSyncService.cs
@@ -1,172 +1,176 @@
-using GregModmanager.Models;
-
-namespace GregModmanager.Services;
-
-///
-/// Synchronizes downloaded Workshop content from Steam's cache into the live game folders
-/// using atomic copy.
-///
-public sealed class ModsFolderSyncService
-{
- public event Action? SyncProgress;
-
- ///
- /// Sync a single downloaded Workshop item into the game's folder based on its .
- ///
- public SyncResult SyncItem(ulong publishedFileId, string steamLocalDir, string gameRoot)
- {
- if (string.IsNullOrEmpty(gameRoot))
- return SyncResult.Fail("Game root path is not configured.");
-
- if (!Directory.Exists(steamLocalDir))
- return SyncResult.Fail($"Source directory does not exist: {steamLocalDir}");
-
- var modType = ReadModTypeFromDirectory(steamLocalDir);
- var destDir = ResolveDestinationPath(gameRoot, publishedFileId, modType);
- var tempDir = destDir + ".tmp";
-
- try
- {
- if (Directory.Exists(tempDir))
- Directory.Delete(tempDir, recursive: true);
-
- Directory.CreateDirectory(tempDir);
- CopyDirectoryRecursive(steamLocalDir, tempDir);
-
- if (Directory.Exists(destDir))
- Directory.Delete(destDir, recursive: true);
-
- Directory.Move(tempDir, destDir);
-
- SyncProgress?.Invoke(new SyncProgressArgs(publishedFileId, true, destDir));
- return SyncResult.Ok(destDir);
- }
- catch (Exception ex)
- {
- try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, recursive: true); }
- catch { /* cleanup best-effort */ }
-
- SyncProgress?.Invoke(new SyncProgressArgs(publishedFileId, false, null));
- return SyncResult.Fail($"Sync failed for {publishedFileId}: {ex.Message}");
- }
- }
-
- private static ModContentType ReadModTypeFromDirectory(string dir)
- {
- var path = Path.Combine(dir, "greg-modmanager.meta.json");
- if (!File.Exists(path))
- return ModContentType.PlacableObject;
-
- try
- {
- using var doc = System.Text.Json.JsonDocument.Parse(File.ReadAllText(path));
- if (doc.RootElement.TryGetProperty("modType", out var prop) &&
- prop.ValueKind == System.Text.Json.JsonValueKind.String)
- {
- return ParseModType(prop.GetString());
- }
- }
- catch { /* ignore parse errors */ }
-
- return ModContentType.PlacableObject;
- }
-
- private static ModContentType ParseModType(string? value)
- => value switch
- {
- "MelonloaderPlugin" or "MelonLoaderPlugin" => ModContentType.MelonloaderPlugin,
- "Userlib" => ModContentType.Userlib,
- "DataCenterMod" or "DataCenterMods" => ModContentType.DataCenterMod,
- _ => ModContentType.PlacableObject,
- };
-
- private static string ResolveDestinationPath(string gameRoot, ulong publishedFileId, ModContentType modType)
- {
- var id = publishedFileId.ToString();
- return modType switch
- {
- ModContentType.MelonloaderPlugin => Path.Combine(gameRoot, "Plugins", id),
- ModContentType.Userlib => Path.Combine(gameRoot, "Plugins", "Dependencies", id),
- ModContentType.DataCenterMod => Path.Combine(gameRoot, "Mods", id),
- _ => Path.Combine(gameRoot, "Data Center_Data", "StreamingAssets", "Mods", id),
- };
- }
-
- ///
- /// Sync multiple items downloaded via .
- ///
- public IReadOnlyList SyncItems(
- IReadOnlyList<(ulong Id, string LocalDir)> items,
- string gameRoot,
- IProgress? log = null)
- {
- var results = new List(items.Count);
- foreach (var (id, localDir) in items)
- {
- log?.Report($"Syncing {id}…");
- var result = SyncItem(id, localDir, gameRoot);
- if (result.Success)
- log?.Report($"Synced {id} → {result.DestinationPath}");
- else
- log?.Report($"Failed {id}: {result.ErrorMessage}");
- results.Add(result);
- }
-
- return results;
- }
-
- ///
- /// Removes a Workshop item from all known installation directories.
- ///
- public bool RemoveItem(ulong publishedFileId, string gameRoot)
- {
- var id = publishedFileId.ToString();
- var candidates = new[]
- {
- Path.Combine(gameRoot, "Data Center_Data", "StreamingAssets", "Mods", id),
- Path.Combine(gameRoot, "Mods", "Workshop", id),
- Path.Combine(gameRoot, "Mods", id),
- Path.Combine(gameRoot, "Plugins", id),
- Path.Combine(gameRoot, "Plugins", "Dependencies", id),
- Path.Combine(gameRoot, "Userlibs", id),
- };
-
- bool anyRemoved = false;
- foreach (var destDir in candidates)
- {
- if (!Directory.Exists(destDir)) continue;
- try
- {
- Directory.Delete(destDir, recursive: true);
- anyRemoved = true;
- }
- catch { /* best-effort */ }
- }
-
- return anyRemoved;
- }
-
- private static void CopyDirectoryRecursive(string sourceDir, string destDir)
- {
- foreach (var dir in Directory.GetDirectories(sourceDir, "*", SearchOption.AllDirectories))
- {
- var relative = Path.GetRelativePath(sourceDir, dir);
- Directory.CreateDirectory(Path.Combine(destDir, relative));
- }
-
- foreach (var file in Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories))
- {
- var relative = Path.GetRelativePath(sourceDir, file);
- File.Copy(file, Path.Combine(destDir, relative), overwrite: true);
- }
- }
-}
-
-public readonly record struct SyncResult(bool Success, string? DestinationPath, string? ErrorMessage)
-{
- public static SyncResult Ok(string path) => new(true, path, null);
- public static SyncResult Fail(string error) => new(false, null, error);
-}
-
-public readonly record struct SyncProgressArgs(ulong PublishedFileId, bool Success, string? DestinationPath);
-
+using GregModmanager.Models;
+
+namespace GregModmanager.Services;
+
+///
+/// Synchronizes downloaded Workshop content from Steam's cache into the live game folders
+/// using atomic copy.
+///
+public sealed class ModsFolderSyncService
+{
+ public event Action? SyncProgress;
+
+ ///
+ /// Sync a single downloaded Workshop item into the game's folder based on its .
+ ///
+ public SyncResult SyncItem(ulong publishedFileId, string steamLocalDir, string gameRoot)
+ {
+ if (string.IsNullOrEmpty(gameRoot))
+ return SyncResult.Fail("Game root path is not configured.");
+
+ if (!Directory.Exists(steamLocalDir))
+ return SyncResult.Fail($"Source directory does not exist: {steamLocalDir}");
+
+ var modType = ReadModTypeFromDirectory(steamLocalDir);
+ var destDir = ResolveDestinationPath(gameRoot, publishedFileId, modType);
+ var tempDir = destDir + ".tmp";
+
+ try
+ {
+ if (Directory.Exists(tempDir))
+ Directory.Delete(tempDir, recursive: true);
+
+ Directory.CreateDirectory(tempDir);
+ CopyDirectoryRecursive(steamLocalDir, tempDir);
+
+ if (Directory.Exists(destDir))
+ Directory.Delete(destDir, recursive: true);
+
+ Directory.Move(tempDir, destDir);
+
+ SyncProgress?.Invoke(new SyncProgressArgs(publishedFileId, true, destDir));
+ return SyncResult.Ok(destDir);
+ }
+ catch (Exception ex)
+ {
+ try { if (Directory.Exists(tempDir)) Directory.Delete(tempDir, recursive: true); }
+ catch { /* cleanup best-effort */ }
+
+ SyncProgress?.Invoke(new SyncProgressArgs(publishedFileId, false, null));
+ return SyncResult.Fail($"Sync failed for {publishedFileId}: {ex.Message}");
+ }
+ }
+
+ private static ModContentType ReadModTypeFromDirectory(string dir)
+ {
+ var path = Path.Combine(dir, "greg-modmanager.meta.json");
+ if (!File.Exists(path))
+ return ModContentType.PlacableObject;
+
+ try
+ {
+ using var doc = System.Text.Json.JsonDocument.Parse(File.ReadAllText(path));
+ if (doc.RootElement.TryGetProperty("modType", out var prop) &&
+ prop.ValueKind == System.Text.Json.JsonValueKind.String)
+ {
+ return ParseModType(prop.GetString());
+ }
+ }
+ catch { /* ignore parse errors */ }
+
+ return ModContentType.PlacableObject;
+ }
+
+ private static ModContentType ParseModType(string? value)
+ => value switch
+ {
+ "MelonloaderPlugin" or "MelonLoaderPlugin" => ModContentType.MelonloaderPlugin,
+ "Userlib" => ModContentType.Userlib,
+ "DataCenterMod" or "DataCenterMods" => ModContentType.DataCenterMod,
+ _ => ModContentType.PlacableObject,
+ };
+
+ private static string ResolveDestinationPath(string gameRoot, ulong publishedFileId, ModContentType modType)
+ {
+ var id = publishedFileId.ToString();
+ return modType switch
+ {
+ ModContentType.MelonloaderPlugin => Path.Combine(gameRoot, "Plugins", id),
+ ModContentType.Userlib => Path.Combine(gameRoot, "Plugins", "Dependencies", id),
+ ModContentType.DataCenterMod => Path.Combine(gameRoot, "Mods", id),
+ _ => Path.Combine(gameRoot, "Data Center_Data", "StreamingAssets", "Mods", id),
+ };
+ }
+
+ ///
+ /// Sync multiple items downloaded via .
+ ///
+ public IReadOnlyList SyncItems(IReadOnlyList<(ulong Id, string LocalDir)> items, string gameRoot)
+ {
+ return SyncItems(items, gameRoot, log: null);
+ }
+
+ public IReadOnlyList SyncItems(
+ IReadOnlyList<(ulong Id, string LocalDir)> items,
+ string gameRoot,
+ IProgress? log)
+ {
+ var results = new List(items.Count);
+ foreach (var (id, localDir) in items)
+ {
+ log?.Report($"Syncing {id}…");
+ var result = SyncItem(id, localDir, gameRoot);
+ if (result.Success)
+ log?.Report($"Synced {id} → {result.DestinationPath}");
+ else
+ log?.Report($"Failed {id}: {result.ErrorMessage}");
+ results.Add(result);
+ }
+
+ return results;
+ }
+
+ ///
+ /// Removes a Workshop item from all known installation directories.
+ ///
+ public bool RemoveItem(ulong publishedFileId, string gameRoot)
+ {
+ var id = publishedFileId.ToString();
+ var candidates = new[]
+ {
+ Path.Combine(gameRoot, "Data Center_Data", "StreamingAssets", "Mods", id),
+ Path.Combine(gameRoot, "Mods", "Workshop", id),
+ Path.Combine(gameRoot, "Mods", id),
+ Path.Combine(gameRoot, "Plugins", id),
+ Path.Combine(gameRoot, "Plugins", "Dependencies", id),
+ Path.Combine(gameRoot, "Userlibs", id),
+ };
+
+ bool anyRemoved = false;
+ foreach (var destDir in candidates)
+ {
+ if (!Directory.Exists(destDir)) continue;
+ try
+ {
+ Directory.Delete(destDir, recursive: true);
+ anyRemoved = true;
+ }
+ catch { /* best-effort */ }
+ }
+
+ return anyRemoved;
+ }
+
+ private static void CopyDirectoryRecursive(string sourceDir, string destDir)
+ {
+ foreach (var dir in Directory.GetDirectories(sourceDir, "*", SearchOption.AllDirectories))
+ {
+ var relative = Path.GetRelativePath(sourceDir, dir);
+ Directory.CreateDirectory(Path.Combine(destDir, relative));
+ }
+
+ foreach (var file in Directory.GetFiles(sourceDir, "*", SearchOption.AllDirectories))
+ {
+ var relative = Path.GetRelativePath(sourceDir, file);
+ File.Copy(file, Path.Combine(destDir, relative), overwrite: true);
+ }
+ }
+}
+
+public readonly record struct SyncResult(bool Success, string? DestinationPath, string? ErrorMessage)
+{
+ public static SyncResult Ok(string path) => new(true, path, null);
+ public static SyncResult Fail(string error) => new(false, null, error);
+}
+
+public readonly record struct SyncProgressArgs(ulong PublishedFileId, bool Success, string? DestinationPath);
From c5d3bf5ce2538eaa4892cfee818078157b6f1453 Mon Sep 17 00:00:00 2001
From: Marvin <52848568+mleem97@users.noreply.github.com>
Date: Wed, 8 Jul 2026 03:26:36 +0200
Subject: [PATCH 13/21] refactor(core): document release debug log no-op
---
.../Services/DebugSessionLog.cs | 142 ++++++++++--------
1 file changed, 77 insertions(+), 65 deletions(-)
diff --git a/src/GregModmanager.Core/Services/DebugSessionLog.cs b/src/GregModmanager.Core/Services/DebugSessionLog.cs
index 7d15d5c..9b1e0ff 100644
--- a/src/GregModmanager.Core/Services/DebugSessionLog.cs
+++ b/src/GregModmanager.Core/Services/DebugSessionLog.cs
@@ -1,65 +1,77 @@
-using System.Text.Json;
-
-namespace GregModmanager;
-
-/// NDJSON debug log (Debug builds only; stripped from Release for size and I/O).
-internal static class DebugSessionLog
-{
-#if DEBUG
- private const string SessionId = "9fc458";
- private static readonly string LogPath = ResolveLogPath();
- private static readonly object Gate = new();
-
- internal static string LogFilePath => LogPath;
-
- private static string ResolveLogPath()
- {
- try
- {
- var dir = new DirectoryInfo(AppContext.BaseDirectory);
- for (var i = 0; i < 14 && dir != null; i++, dir = dir.Parent)
- {
- var csproj = Path.Combine(dir.FullName, "GregModmanager", "GregModmanager.csproj");
- if (File.Exists(csproj))
- return Path.Combine(dir.FullName, "debug-9fc458.log");
- }
- }
- catch
- {
- // ignored
- }
-
- return Path.Combine(Path.GetTempPath(), "debug-9fc458.log");
- }
-
- public static void Write(string hypothesisId, string location, string message, object? data = null)
- {
- try
- {
- var line = JsonSerializer.Serialize(new
- {
- sessionId = SessionId,
- hypothesisId,
- location,
- message,
- timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
- runId = Environment.GetEnvironmentVariable("WORKSHOP_UPLOADER_DEBUG_RUN") ?? "pre-fix",
- data,
- });
- lock (Gate)
- {
- File.AppendAllText(LogPath, line + Environment.NewLine);
- }
- }
- catch
- {
- // never break startup
- }
- }
-#else
- internal static string LogFilePath => string.Empty;
-
- public static void Write(string hypothesisId, string location, string message, object? data = null) { }
-#endif
-}
-
+using System.Text.Json;
+
+namespace GregModmanager;
+
+/// NDJSON debug log (Debug builds only; stripped from Release for size and I/O).
+internal static class DebugSessionLog
+{
+#if DEBUG
+ private const string SessionId = "9fc458";
+ private static readonly string LogPath = ResolveLogPath();
+ private static readonly object Gate = new();
+
+ internal static string LogFilePath => LogPath;
+
+ private static string ResolveLogPath()
+ {
+ try
+ {
+ var dir = new DirectoryInfo(AppContext.BaseDirectory);
+ for (var i = 0; i < 14 && dir != null; i++, dir = dir.Parent)
+ {
+ var csproj = Path.Combine(dir.FullName, "GregModmanager", "GregModmanager.csproj");
+ if (File.Exists(csproj))
+ return Path.Combine(dir.FullName, "debug-9fc458.log");
+ }
+ }
+ catch
+ {
+ // ignored
+ }
+
+ return Path.Combine(Path.GetTempPath(), "debug-9fc458.log");
+ }
+
+ public static void Write(string hypothesisId, string location, string message)
+ {
+ Write(hypothesisId, location, message, data: null);
+ }
+
+ public static void Write(string hypothesisId, string location, string message, object? data)
+ {
+ try
+ {
+ var line = JsonSerializer.Serialize(new
+ {
+ sessionId = SessionId,
+ hypothesisId,
+ location,
+ message,
+ timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
+ runId = Environment.GetEnvironmentVariable("WORKSHOP_UPLOADER_DEBUG_RUN") ?? "pre-fix",
+ data,
+ });
+ lock (Gate)
+ {
+ File.AppendAllText(LogPath, line + Environment.NewLine);
+ }
+ }
+ catch
+ {
+ // never break startup
+ }
+ }
+#else
+ internal static string LogFilePath => string.Empty;
+
+ public static void Write(string hypothesisId, string location, string message)
+ {
+ Write(hypothesisId, location, message, data: null);
+ }
+
+ public static void Write(string hypothesisId, string location, string message, object? data)
+ {
+ // Release builds intentionally discard debug-session events to avoid user I/O and package-size overhead.
+ }
+#endif
+}
From 750d09775b75ca1df1f85b1cde861cc3839a7ca2 Mon Sep 17 00:00:00 2001
From: Marvin <52848568+mleem97@users.noreply.github.com>
Date: Wed, 8 Jul 2026 03:26:57 +0200
Subject: [PATCH 14/21] docs: modularize agent instructions
---
AGENTS.md | 389 ++++++------------------------------------------------
1 file changed, 42 insertions(+), 347 deletions(-)
diff --git a/AGENTS.md b/AGENTS.md
index a489af7..7d42d8c 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -1,365 +1,60 @@
# AGENTS.md — gregModmanager
-This file contains agent-oriented instructions for the `gregModmanager` repository.
-Read this before modifying code, building, or creating pull requests.
+This file is the short entry point for agent-oriented repository instructions. Read the referenced companion files before changing code, builds, documentation, or pull requests.
----
+## Companion files
-## 1. Project Context
+- `SOUL.md` — repository personality, collaboration defaults, and communication style.
+- `USER.md` — project context, architecture, runtime guardrails, and release expectations.
+- `TOOLS.md` — build, CI, Steam Workshop, signing, telemetry, and troubleshooting guidance.
+- `EXTERNAL_DEPENDENCIES.md` — external services, tools, native binaries, and third-party dependencies.
-- **Application**: Cross-platform desktop Mod Manager for the gregFramework ecosystem.
-- **UI Framework**: Avalonia UI 11.2 (replaces legacy MAUI).
-- **Target Framework**: .NET 9 (`net9.0`).
-- **Primary RID**: `win-x64` (Windows), `linux-x64` (Linux).
-- **Steam AppID**: `4170200` (Data Center).
-- **Solution**: `GregModmanager.sln`
-- **Executable Project**: `src/GregModmanager.Avalonia/GregModmanager.Avalonia.csproj`
-- **Shared Library**: `src/GregModmanager.Core/GregModmanager.Core.csproj`
+## Project context
----
+- Application: cross-platform desktop mod manager for the gregFramework ecosystem.
+- UI: Avalonia UI 11.2.
+- Target framework: .NET 9 for the desktop app; runtime-facing helper projects stay compatible with .NET 6 unless explicitly requested and validated.
+- Solution: `GregModmanager.sln`.
+- Executable project: `src/GregModmanager.Avalonia/GregModmanager.Avalonia.csproj`.
+- Shared library: `src/GregModmanager.Core/GregModmanager.Core.csproj`.
-## 2. Architecture Rules
+## Required workflow
-### 2.1 Project Layout
+- Work on a feature branch and open a pull request to `main`; do not push directly to `main` unless the maintainer explicitly requests it.
+- Keep commits focused. Combine related changes only when splitting them would reduce reviewability or when the maintainer asks for a single commit.
+- Use Conventional Commits for generated commit messages unless the maintainer explicitly requests another format.
+- Update `CHANGELOG.md` under `[Unreleased]` for user-facing changes.
+- Update `EXTERNAL_DEPENDENCIES.md` when adding, removing, or materially changing external packages, tools, services, or native binaries.
-- `src/GregModmanager.Avalonia/` — Avalonia executable, Views, DI setup, custom chrome window.
-- `src/GregModmanager.Core/` — Shared services, models, Steam integration, localization.
-- `src/SubDirectoryFixer/` — net6.0 helper project (MelonLoader plugin).
-- `tests/GregModmanager.Tests/` — xUnit test project.
-- `build/scripts/` — All build and automation scripts.
-- `build/installer/` — Inno Setup script (`gregModmanager.iss`) and signing tools.
-- `wiki/` — **Git submodule** pointing to `https://github.com/mleem97/gregModmanager.wiki.git`.
- - Do NOT commit wiki contents into the main repo.
- - Update the submodule pointer after wiki changes.
-- All Documentation and Correspondence must use the English Language.
+## Architecture guardrails
-### 2.2 Dependency Direction
+- Core should not reference Avalonia. If a required fix appears to need that dependency, stop and ask for maintainer confirmation.
+- Avalonia may depend on Core through ``.
+- Use dependency injection in `Program.cs` for services.
+- Prefer `Path.Combine`, `Environment.SpecialFolder`, known paths, or validated user paths over hard-coded platform paths.
+- Guard platform-specific code with compile-time or runtime platform checks unless the maintainer explicitly confirms a platform-only change.
-- Core must never reference Avalonia.
-- Avalonia depends on Core via ``.
-- Use `Microsoft.Extensions.DependencyInjection` for service registration in `Program.cs`.
+## Steam Workshop guardrails
-### 2.3 Cross-Platform Constraints
+- Check `SteamPublishRateLimiter.Shared.TryAcquire(out retryAfter)` before `SubmitAsync()` unless the maintainer explicitly confirms a controlled test path.
+- Display cooldown timers in seconds when the UI exposes rate-limit state unless the UX owner confirms a different copy format.
+- Keep `steam_api64.dll` out of Authenticode signing loops unless the vendor changes the binary format and the maintainer confirms the change.
-- Prefer `System.IO.Path.Combine` over string concatenation.
-- Use `Environment.SpecialFolder` or known paths; never hard-code Windows paths in Core.
-- Platform-specific code must be guarded with `#if WINDOWS` or runtime checks.
-- Linux packaging requires `tar`, `dotnet`, and optionally `wsl` + `nfpm`.
+## Build and release guardrails
----
+- Keep PowerShell build scripts and GitHub Actions workflows aligned unless a PR intentionally stages a migration and explains the temporary divergence.
+- Preserve publish-size settings unless a measured, reviewed change requires otherwise.
+- Breaking changes normally require a major SemVer bump; ask the maintainer before applying a different release policy.
+- Manual release promotion is allowed only when the automated workflow is unavailable or the maintainer requests it.
-## 3. Avalonia UI Best Practices
+## JSON and localization
-### 3.1 Window Chrome
+- Register serialized DTOs in `src/GregModmanager.Core/Models/AppJsonContext.cs` unless the type is intentionally excluded and documented.
+- Add UI strings to `AppStrings.resx` and `AppStrings.de.resx` at minimum unless the maintainer explicitly limits localization scope.
+- Repository documentation should be English unless the user or maintainer explicitly requests another language for the artifact.
-- Use custom borderless window: `SystemDecorations="None"`.
-- Do NOT use `ExtendClientAreaToDecorationsHint="True"` if it breaks dragging.
-- Implement drag-to-move via `PointerPressed` + `BeginMoveDrag` on the title bar `Border`.
+## Final checks
-### 3.2 Data Binding
-
-- Prefer `CompiledBindings` where possible to reduce reflection and trimming warnings.
-- Avoid `ReflectionBindingExtension` in performance-critical paths.
-
-### 3.3 Styling (Terminal Core)
-
-- Design system: **Terminal Core**.
-- 8px grid alignment.
-- Sharp geometry: radius `<= 8px` on containers.
-- Hairline separators (`#122131` on `#051424`).
-- Monospace for metadata and status values.
-- Color tokens:
- - Surface base: `#051424`
- - Primary: `#8AEBFF`
- - Secondary: `#4DE082`
- - On-surface: `#D4E4FA`
-- No soft shadows, no rounded bubble UI.
-
-### 3.5 Telemetry & Health Monitoring
-
-- **Backend**: Self-hosted Loki at `telemetry.datacentermods.com`.
-- **Authentication**: Uses `X-Scope-OrgID: managerclient` for multi-tenancy.
-- **Privacy**: Respects `AppSettings.TelemetryEnabled`. Anonymized `machine_id` is used for installation tracking.
-- **Implementation**: `TelemetryService.cs` handles structured JSON events and crash report flushing.
-- **Startup Resilience**: Eager reporting in `Program.Main` sends previous crash reports before full UI initialization.
-
-### 3.6 Online Installer (Windows)
-
-- **Tool**: Inno Setup 6.
-- **Dependencies**: Automatically detects and installs Microsoft Visual C++ Redistributable (x64).
-- **Online Capability**: Downloads required runtimes via PowerShell during the installation process if missing.
-
----
-
-## 4. Steam & Workshop Rules
-
-### 4.1 Rate Limiting
-
-- `SteamPublishRateLimiter.Shared` enforces:
- - **30 seconds** minimum between publish attempts.
- - **5 attempts** per **10-minute** rolling window.
-- Always check `TryAcquire(out retryAfter)` before calling `SubmitAsync()`.
-- UI must display explicit cooldown timers in seconds.
-
-### 4.2 Native DLL Handling
-
-- `steam_api64.dll` must NEVER be Authenticode-signed (it is not a valid PE for signing).
-- Exclude it from `signtool` / `Get-AuthenticodeSignature` loops.
-
-### 4.3 Game Root & Paths
-
-- Game root resolves via Steam API (`SteamApps.AppInstallDir(4170200)`) or user preference.
-- Workshop sync targets depend on `ModContentType`:
-
- - `PlacableObject` → `{GameRoot}/Mods/Workshop/{id}/`
- - `MelonloaderPlugin` → `{GameRoot}/Plugins/{id}/`
- - `Userlib` → `{GameRoot}/Userlibs/{id}/`
- - `DataCenterMod` → `{GameRoot}/Mods/{id}/`
-
----
-
-## 5. Build & Release Rules
-
-### 5.1 Size Optimization (Critical)
-
-Publish settings must include:
-
-```xml
-true
-full
-true
-true
-none
-false
-```
-
-- Target size: **< 50 MB** (currently ~38 MB).
-- Avoid adding large native dependencies without measuring publish output.
-
-### 5.2 Build Orchestration
-
-- **Local builds**: use `build/scripts/build.ps1` (mirrors CI exactly).
-- **Interactive builder**: use `build/builder.ps1` or `build/builder.sh`.
-- CI workflow: `.github/workflows/build-and-release.yml`.
-- The PowerShell script and the GitHub Actions workflow must stay in sync.
-
-### 5.3 Signing
-
-- Windows binaries: Authenticode via `build/installer/sign-authenticode.ps1`.
-- Environment: `CODE_SIGN_THUMBPRINT` (store cert) or `CODE_SIGN_PFX` + `CODE_SIGN_PFX_PASSWORD`.
-- If no cert is configured, the build script creates an ephemeral self-signed cert.
-
-### 5.4 Versioning
-
-- Version lives in `src/GregModmanager.Avalonia/GregModmanager.Avalonia.csproj` `` property.
-- Numeric version: `x.y.z` → `x.y.z.0` for Inno Setup.
-- Single source of truth: the `.csproj` file.
-
----
-
-## 6. Coding Standards
-
-### 6.1 General
-
-- Use `init` or `required` for DTO properties where applicable.
-- Prefer `record struct` for small immutable value types.
-- Use `CancellationToken` on all async service methods.
-- Keep UI components compact and data-dense.
-
-### 6.2 JSON Serialization & Models
-
-- Use `System.Text.Json` with `[JsonPropertyName(...)]` attributes.
-- **Central Model Management**: All data models (DTOs) should be placed in `src/GregModmanager.Core/Models/`.
-- **JSON Source Generation (AOT Support)**: All models requiring serialization MUST be registered in `src/GregModmanager.Core/Models/AppJsonContext.cs` via `[JsonSerializable]`.
-- Avoid polymorphic deserialization without source generators (trimming-safe).
-- If trimming warnings appear for JSON code, verify the type is in `AppJsonContext`.
-
-### 6.3 Localization
-
-- Strings are stored in `Resources/Strings/AppStrings.resx` + satellite `.de.resx`, `.es.resx`, etc.
-- Access via `GregModmanager.Localization.S.Get("Key")` or `S.Format("Key", args)`.
-- When adding UI text, add entries to `AppStrings.resx` and `AppStrings.de.resx` at minimum.
-
-### 6.4 Git Workflow
-
-- Create a **feature branch** for every change.
-- Open a **Pull Request** to `main`; do not push directly to `main`.
-- Keep commits atomic and focused.
-- Update `AGENTS.md` if you change build steps, architecture, or agent instructions.
-
----
-
-## 7. Troubleshooting Quick Reference
-
-| Symptom | Fix |
-| :--- | :--- |
-| Avalonia window has duplicate title bars | Remove `ExtendClientAreaToDecorationsHint` or set to `False`. |
-| `steam_api64.dll` signing fails with `0x800700C1` | Skip it in the signing loop. |
-| Linux package build fails | Ensure `nfpm` is installed and in `PATH`. |
-| Steam upload blocked | Check `SteamPublishRateLimiter` cooldown banner. |
-| Trimming warnings in JSON code | Use source generators or add roots to `TrimmerRoots.xml`. |
-
----
-
-## 8. External Resources
-
-- **Wiki (submodule)**: `wiki/Home.md`
-- **Inno Setup**:
-- **Avalonia Docs**:
-- **nfpm**:
-
-## Core Runtime Guardrails
-
-- Keep all gameplay/runtime-facing components compatible with `.NET 6.x`.
-- This also applies to any shared libraries, Melonloader Plugins or Game Mods that may be used, within this Project, and/or in Unity IL2CPP + MelonLoader contexts.
-- Do not retarget runtime projects beyond `net6.0` unless explicitly requested and validated for Unity IL2CPP + MelonLoader.
-
-## Mandatory System Architecture Prompt
-
-- Apply `.github/instructions/gregframework_system_architecture.instructions.md` to all implementation and design decisions.
-- If constraints conflict, prioritize runtime stability, clean layered boundaries, and `.NET 6` compatibility.
-
-## SonarQube MCP Rules
-
-- Apply `.github/instructions/sonarqube_mcp.instructions.md` whenever SonarQube MCP tooling is used.
-
-## Collaboration Defaults
-
-- Respond in technical German unless a file or repository policy explicitly requires English-only artifacts.
-- Summarize intent before code changes.
-- Keep refactors minimal and architecture-safe.
-
-## Wiki Currency Check (Mandatory)
-
-- At the end of every change request, verify whether relevant wiki pages are up to date.
-- If updates are required, list the pages and include them in follow-up recommendations.
-
----
-
-## 9. Versioning, Commits & Changelog
-
-### 9.1 Semantic Versioning (SemVer)
-
-This project follows [Semantic Versioning 2.0.0](https://semver.org/).
-
-- **Single source of truth:** `src/GregModmanager.Avalonia/GregModmanager.Avalonia.csproj` `` property.
-- Format: `MAJOR.MINOR.PATCH[-prerelease]` (e.g. `1.5.0`, `1.6.0-alpha1`).
-- **Bump rules:**
- - `MAJOR` — breaking API or behavior changes (public interfaces, save-data formats, CLI arguments).
- - `MINOR` — new features, new mod content types, new UI pages, backward-compatible additions.
- - `PATCH` — bug fixes, security patches, performance improvements, docs corrections.
- - Prerelease identifiers (`-alpha`, `-beta`, `-pre`) produce artifacts with `-pre` suffix.
-
-### 9.2 Conventional Commits
-
-All commits **MUST** follow the [Conventional Commits](https://www.conventionalcommits.org/) specification.
-
-**Structure:**
-
-```text
-[optional scope]:
-
-[optional body]
-
-[optional footer(s)]
-```
-
-**Types and SemVer mapping:**
-
-| Type | SemVer impact | Use for |
-| :--- | :--- | :--- |
-| `feat` | MINOR | New features, new UI pages, new public APIs |
-| `fix` | PATCH | Bug fixes, crash fixes, security patches |
-| `perf` | PATCH | Performance improvements |
-| `refactor` | PATCH | Code restructuring without behavior change |
-| `docs` | — | Documentation and comment changes |
-| `style` | — | Formatting, whitespace, semicolons |
-| `test` | — | Adding or correcting tests |
-| `build` | — | Build scripts, CI, dependencies |
-| `ci` | — | GitHub Actions, workflow changes |
-| `chore` | — | Routine maintenance, dependency bumps |
-
-**Breaking changes:**
-
-- Append `!` after type/scope: `feat(api)!: remove legacy upload endpoint`
-- OR add footer: `BREAKING CHANGE: old mod format no longer supported`
-- Breaking changes **always** trigger a **MAJOR** bump.
-
-**Examples:**
-
-```text
-feat(workshop): add PlacableObject mod type routing
-
-Implements subdirectory routing for PlacableObject mods
-to {GameRoot}/Mods/Workshop/{id}/.
-
-Refs: #42
-```
-
-```text
-fix(steam): prevent rate limit bypass in rapid publish
-
-SteamPublishRateLimiter now correctly resets the rolling
-window after 10 minutes.
-```
-
-```text
-feat(auth)!: replace username/password with Steam OpenID
-
-BREAKING CHANGE: local credential store is no longer read.
-Users must re-authenticate via Steam.
-```
-
-**Agent rules when generating commits:**
-
-- Every commit must have a type prefix.
-- Scope is recommended for commits touching `Steam`, `Workshop`, `UI`, `Build`, `CI`.
-- Description uses imperative mood (`add`, not `added` or `adds`).
-- Body explains **why**, not just what.
-- Footer references issues/PRs with `Refs: #123`, `Closes: #456`, `Fixes: #789`.
-- **Never** combine unrelated changes in a single commit.
-
-### 9.3 Changelog Maintenance
-
-This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
-
-- **File:** `CHANGELOG.md` at repository root.
-- **Format:** Markdown with `## [Version] - YYYY-MM-DD` headers.
-- **Sections per version:** `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`.
-
-**Agent workflow:**
-
-1. When implementing a feature/fix, add an entry to the `[Unreleased]` section immediately.
-2. Entries should be concise, user-facing descriptions (not commit messages).
-3. Group related entries under the correct subsection.
-4. **Do not** edit released version sections.
-
-**Release promotion:**
-
-- The `[Unreleased]` header is promoted to a versioned header **only** by the automated workflow (`.github/workflows/promote-changelog.yml`).
-- The workflow is triggered manually via `workflow_dispatch` with the target version.
-- The workflow:
- 1. Renames `[Unreleased]` to `## [x.y.z] - YYYY-MM-DD`.
- 2. Inserts a new empty `[Unreleased]` block at the top.
- 3. Bumps `` in `src/GregModmanager.Avalonia/GregModmanager.Avalonia.csproj`.
- 4. Commits to `main` and pushes.
- 5. Optionally creates and pushes git tag `vx.y.z` (if `create_tag` is true).
-- After promotion, the `build-and-release.yml` workflow triggers automatically from the tag.
-
-**Manual emergency override:**
-If the automated workflow is unavailable, an agent **may** perform the promotion manually by editing `CHANGELOG.md` and the `.csproj` file in a single commit with message:
-
-```text
-chore(release): promote changelog and bump version to x.y.z
-```
-
-### 9.4 Release Checklist (Agent Responsibility)
-
-Before triggering the promote workflow:
-
-- [ ] `CHANGELOG.md` `[Unreleased]` section is complete and accurate.
-- [ ] All referenced issues/PRs are closed or documented.
-- [ ] `AGENTS.md` updated if architecture or workflow changed.
-- [ ] `EXTERNAL_DEPENDENCIES.md` updated if new packages added.
-- [ ] Version in `.csproj` matches intended release (workflow will overwrite anyway, but verify).
-- [ ] `main` branch CI is green.
+- Run the most relevant build, test, or static check available in the environment.
+- If a check cannot be run, state that limitation in the PR or final response.
+- Verify whether relevant wiki pages need updates; list follow-up pages when the wiki is not available in the working environment.
From f1fa66ec2dbd4fb8c4603b529181fa56eff04984 Mon Sep 17 00:00:00 2001
From: Marvin <52848568+mleem97@users.noreply.github.com>
Date: Wed, 8 Jul 2026 03:27:02 +0200
Subject: [PATCH 15/21] docs: add agent collaboration defaults
---
SOUL.md | 20 ++++++++++++++++++++
1 file changed, 20 insertions(+)
create mode 100644 SOUL.md
diff --git a/SOUL.md b/SOUL.md
new file mode 100644
index 0000000..e91c91b
--- /dev/null
+++ b/SOUL.md
@@ -0,0 +1,20 @@
+# SOUL.md — Collaboration Defaults
+
+## Communication
+
+- Use clear, technical language.
+- Prefer concise summaries before code changes.
+- Respond in German when the user writes German, unless the requested artifact or repository policy requires English.
+- Keep documentation artifacts in English unless the user or maintainer explicitly requests another language.
+
+## Review posture
+
+- Keep refactors minimal and architecture-safe.
+- Prefer observable behavior preservation for static-analysis cleanup.
+- Call out skipped checks, unavailable tooling, or incomplete verification explicitly.
+
+## Agent behavior
+
+- Do not hide uncertainty in PR descriptions or final responses.
+- Do not invent tool output.
+- If repository guardrails conflict with a direct maintainer request, ask for confirmation before proceeding.
From fa98b805ce49a7d5a735ef8d95c433d2e7eb4b1b Mon Sep 17 00:00:00 2001
From: Marvin <52848568+mleem97@users.noreply.github.com>
Date: Wed, 8 Jul 2026 03:27:09 +0200
Subject: [PATCH 16/21] docs: add project context reference
---
USER.md | 33 +++++++++++++++++++++++++++++++++
1 file changed, 33 insertions(+)
create mode 100644 USER.md
diff --git a/USER.md b/USER.md
new file mode 100644
index 0000000..a32580e
--- /dev/null
+++ b/USER.md
@@ -0,0 +1,33 @@
+# USER.md — Project Context
+
+## Repository
+
+- Application: gregModmanager.
+- Purpose: cross-platform desktop mod manager for the gregFramework ecosystem.
+- UI framework: Avalonia UI 11.2.
+- Desktop target: .NET 9.
+- Runtime-facing helper compatibility: .NET 6 unless explicitly requested and validated.
+- Primary solution: `GregModmanager.sln`.
+
+## Important paths
+
+- Desktop app: `src/GregModmanager.Avalonia/`.
+- Core services and models: `src/GregModmanager.Core/`.
+- SubDirectoryFixer helper: `src/SubDirectoryFixer/`.
+- Tests: `tests/GregModmanager.Tests/`.
+- Build scripts: `build/scripts/`.
+- Installer assets: `build/installer/`.
+- Wiki submodule: `wiki/`.
+
+## Architecture
+
+- Core remains platform-agnostic and should not reference Avalonia.
+- Avalonia references Core.
+- Service registration belongs in the Avalonia `Program.cs` composition root.
+- Platform-specific behavior should be behind runtime checks or compile-time guards.
+
+## Release context
+
+- Version source of truth: `src/GregModmanager.Avalonia/GregModmanager.Avalonia.csproj`.
+- Changelog source of truth: `CHANGELOG.md`.
+- Release promotion normally runs through `.github/workflows/promote-changelog.yml`.
From af7e7a858a3ad0ee8d1e832ad32ee4034dbe741a Mon Sep 17 00:00:00 2001
From: Marvin <52848568+mleem97@users.noreply.github.com>
Date: Wed, 8 Jul 2026 03:27:15 +0200
Subject: [PATCH 17/21] docs: add tool and build reference
---
TOOLS.md | 33 +++++++++++++++++++++++++++++++++
1 file changed, 33 insertions(+)
create mode 100644 TOOLS.md
diff --git a/TOOLS.md b/TOOLS.md
new file mode 100644
index 0000000..02f9683
--- /dev/null
+++ b/TOOLS.md
@@ -0,0 +1,33 @@
+# TOOLS.md — Build, CI, and Operations
+
+## Build
+
+- Local build script: `build/scripts/build.ps1`.
+- Interactive builders: `build/builder.ps1` and `build/builder.sh`.
+- CI workflow: `.github/workflows/build-and-release.yml`.
+- Keep script and workflow changes aligned unless the PR explicitly documents a staged migration.
+
+## Steam Workshop
+
+- Steam AppID for Data Center: `4170200`.
+- `SteamPublishRateLimiter.Shared` enforces cooldown behavior for publish attempts.
+- UI cooldown copy should show seconds when rate-limit state is visible.
+
+## Signing
+
+- Windows signing uses `build/installer/sign-authenticode.ps1`.
+- Supported signing inputs: `CODE_SIGN_THUMBPRINT`, or `CODE_SIGN_PFX` plus `CODE_SIGN_PFX_PASSWORD`.
+- Do not include `steam_api64.dll` in signing loops unless the maintainer confirms the vendor binary is safe to sign.
+
+## Telemetry
+
+- Telemetry implementation: `TelemetryService.cs`.
+- Telemetry must respect `AppSettings.TelemetryEnabled`.
+- Installation tracking uses an anonymized machine identifier.
+
+## Troubleshooting
+
+- Duplicate Avalonia title bars: check custom-chrome settings.
+- Steam upload blocked: inspect rate-limit state and cooldown copy.
+- JSON trimming warnings: verify source-generation registration in `AppJsonContext`.
+- Linux package build failures: verify external packaging tools listed in `EXTERNAL_DEPENDENCIES.md`.
From 1fa1cfd4cb92b7b27ce7b7e4cd8cef595dd15ebc Mon Sep 17 00:00:00 2001
From: Marvin <52848568+mleem97@users.noreply.github.com>
Date: Wed, 8 Jul 2026 03:28:28 +0200
Subject: [PATCH 18/21] fix(ci): constrain malicious scan subprocess calls
---
.github/scripts/malicious_scan.py | 715 ++++++++++++++++--------------
1 file changed, 393 insertions(+), 322 deletions(-)
diff --git a/.github/scripts/malicious_scan.py b/.github/scripts/malicious_scan.py
index 02af8fc..b0fff22 100644
--- a/.github/scripts/malicious_scan.py
+++ b/.github/scripts/malicious_scan.py
@@ -1,322 +1,393 @@
-#!/usr/bin/env python3
-import argparse
-import json
-import re
-import subprocess
-from dataclasses import dataclass
-from pathlib import Path
-from typing import Dict, List, Tuple
-
-
-@dataclass
-class Finding:
- rule_id: str
- message: str
- severity: str
- file_path: str
- start_line: int
- description: str
-
-
-SCAN_EXTENSIONS = {
- ".cs", ".csx", ".ps1", ".psm1", ".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
- ".sh", ".bash", ".zsh", ".cmd", ".bat", ".yml", ".yaml", ".json", ".toml", ".ini", ".env",
- ".sql", ".rb", ".php", ".java", ".kt", ".go", ".rs",
-}
-
-
-def run(cmd: List[str]) -> str:
- result = subprocess.run(
- cmd,
- capture_output=True,
- text=True,
- encoding="utf-8",
- errors="replace",
- check=False,
- )
- if result.returncode != 0:
- raise RuntimeError(f"Command failed ({' '.join(cmd)}): {result.stderr.strip()}")
- return result.stdout or ""
-
-
-def git_changed_files(days: int) -> List[str]:
- out = run([
- "git",
- "log",
- f"--since={days} days ago",
- "--name-only",
- "--pretty=format:",
- ])
- files = sorted({line.strip() for line in out.splitlines() if line.strip()})
- return [f for f in files if Path(f).is_file()]
-
-
-def is_scannable(path: Path) -> bool:
- suffix = path.suffix.lower()
- if suffix not in SCAN_EXTENSIONS:
- return False
- if any(part.startswith(".") and part not in {".github"} for part in path.parts):
- return False
- return True
-
-
-def git_added_lines(days: int) -> Dict[str, List[Tuple[int, str]]]:
- out = run([
- "git",
- "log",
- f"--since={days} days ago",
- "--patch",
- "--unified=0",
- "--pretty=format:",
- ])
-
- file_lines: Dict[str, List[Tuple[int, str]]] = {}
- current_file: str = ""
- current_new_line: int = 0
-
- for raw in out.splitlines():
- line = raw.rstrip("\n")
-
- if line.startswith("+++ b/"):
- candidate = line[6:]
- path = Path(candidate)
- if path.is_file() and is_scannable(path):
- current_file = candidate
- file_lines.setdefault(current_file, [])
- else:
- current_file = ""
- continue
-
- if line.startswith("@@"):
- match = re.search(r"\+(\d+)(?:,(\d+))?", line)
- if match:
- current_new_line = int(match.group(1))
- continue
-
- if not current_file:
- continue
-
- if line.startswith("+") and not line.startswith("+++"):
- file_lines[current_file].append((current_new_line, line[1:]))
- current_new_line += 1
- elif line.startswith("-"):
- continue
- else:
- current_new_line += 1
-
- return {k: v for k, v in file_lines.items() if v}
-
-
-def git_recent_commits(days: int) -> List[str]:
- out = run([
- "git",
- "log",
- f"--since={days} days ago",
- "--pretty=format:%h - %an, %ar : %s",
- ])
- return [line for line in out.splitlines() if line.strip()]
-
-
-def first_match_line(added_lines: List[Tuple[int, str]], pattern: re.Pattern) -> int:
- for line_number, text in added_lines:
- if pattern.search(text):
- return line_number
- return 1
-
-
-def map_severity(score: int) -> str:
- if score >= 7:
- return "error"
- if score >= 3:
- return "warning"
- return "note"
-
-
-def analyze_files(added_by_file: Dict[str, List[Tuple[int, str]]]) -> List[Finding]:
- findings: List[Finding] = []
-
- secret_re = re.compile(r"(secret|token|password|apikey|api[_-]?key|private[_-]?key)", re.IGNORECASE)
- secret_assignment_re = re.compile(r"(secret|token|password|apikey|api[_-]?key|private[_-]?key)\s*[:=]\s*['\"][^'\"]{8,}", re.IGNORECASE)
- network_re = re.compile(r"(curl\s|wget\s|http[s]?://|requests\.|fetch\(|http\.get|HttpClient|Invoke-RestMethod)", re.IGNORECASE)
- system_re = re.compile(r"(Process\.Start|cmd\.exe|powershell\.exe|/bin/sh|subprocess\.|Runtime\.getRuntime\(\)\.exec)", re.IGNORECASE)
- obfuscation_re = re.compile(r"([A-Za-z0-9+/]{120,}={0,2}|\\x[0-9a-fA-F]{2}\\x[0-9a-fA-F]{2}\\x[0-9a-fA-F]{2,})")
-
- for file_path, added_lines in added_by_file.items():
- added_text = "\n".join(text for _, text in added_lines)
-
- has_secret = bool(secret_re.search(added_text))
- has_secret_assignment = bool(secret_assignment_re.search(added_text))
- has_network = bool(network_re.search(added_text))
-
- if has_secret_assignment and has_network:
- findings.append(Finding(
- rule_id="malicious-code-scanner/secret-exfiltration",
- message="Potential secret exfiltration pattern detected",
- severity=map_severity(9),
- file_path=file_path,
- start_line=min(first_match_line(added_lines, secret_assignment_re), first_match_line(added_lines, network_re)),
- description=(
- "Threat score: 9/10. This file contains both secret-related terms and network transfer patterns. "
- "Review if any sensitive values are read and transmitted externally."
- ),
- ))
-
- if has_network and not has_secret:
- findings.append(Finding(
- rule_id="malicious-code-scanner/suspicious-network",
- message="Unusual network activity pattern in recent changes",
- severity=map_severity(5),
- file_path=file_path,
- start_line=first_match_line(added_lines, network_re),
- description=(
- "Threat score: 5/10. Network-related operations were introduced in recent changes. "
- "Verify destination domains and business justification."
- ),
- ))
-
- if system_re.search(added_text):
- findings.append(Finding(
- rule_id="malicious-code-scanner/system-access",
- message="Suspicious system/process execution pattern",
- severity=map_severity(6),
- file_path=file_path,
- start_line=first_match_line(added_lines, system_re),
- description=(
- "Threat score: 6/10. Process or shell execution pattern detected. "
- "Validate command safety and ensure no user-controlled command injection path exists."
- ),
- ))
-
- if obfuscation_re.search(added_text):
- findings.append(Finding(
- rule_id="malicious-code-scanner/obfuscation",
- message="Possible obfuscation or encoded payload pattern",
- severity=map_severity(4),
- file_path=file_path,
- start_line=first_match_line(added_lines, obfuscation_re),
- description=(
- "Threat score: 4/10. Long encoded/obfuscated-looking strings detected. "
- "Confirm these are expected assets/config blobs and not hidden payloads."
- ),
- ))
-
- dedup = {}
- for f in findings:
- key = (f.rule_id, f.file_path, f.start_line)
- if key not in dedup:
- dedup[key] = f
- return list(dedup.values())
-
-
-def make_sarif(findings: List[Finding]) -> dict:
- rules = {}
- for f in findings:
- if f.rule_id not in rules:
- rules[f.rule_id] = {
- "id": f.rule_id,
- "name": f.rule_id.split("/")[-1],
- "shortDescription": {"text": f.message},
- "help": {"text": f.description},
- "properties": {"tags": ["security", "malicious-code-scan"]},
- }
-
- results = []
- for f in findings:
- results.append({
- "ruleId": f.rule_id,
- "level": f.severity,
- "message": {"text": f.message},
- "locations": [{
- "physicalLocation": {
- "artifactLocation": {"uri": f.file_path.replace('\\\\', '/')},
- "region": {"startLine": max(1, f.start_line)},
- }
- }],
- })
-
- return {
- "version": "2.1.0",
- "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
- "runs": [{
- "tool": {
- "driver": {
- "name": "Malicious Code Scanner",
- "informationUri": "https://github.com",
- "rules": list(rules.values()),
- }
- },
- "results": results,
- }],
- }
-
-
-def main() -> int:
- parser = argparse.ArgumentParser()
- parser.add_argument("--days", type=int, default=3)
- parser.add_argument("--output", required=True)
- parser.add_argument("--summary", required=True)
- args = parser.parse_args()
-
- try:
- commits = git_recent_commits(args.days)
- files = [f for f in git_changed_files(args.days) if is_scannable(Path(f))]
- added_lines = git_added_lines(args.days)
- findings = analyze_files(added_lines)
- sarif = make_sarif(findings)
-
- Path(args.output).write_text(json.dumps(sarif, indent=2), encoding="utf-8")
-
- summary_lines = [
- "Daily malicious code scan completed.",
- f"Analysis window: last {args.days} days",
- f"Commits reviewed: {len(commits)}",
- f"Files analyzed: {len(files)}",
- f"Findings: {len(findings)}",
- "Patterns checked: secret-exfiltration, suspicious-network, system-access, obfuscation",
- ]
-
- if commits:
- summary_lines.append("")
- summary_lines.append("Recent commits:")
- summary_lines.extend([f"- {c}" for c in commits[:30]])
-
- if findings:
- summary_lines.append("")
- summary_lines.append("Findings:")
- for f in findings:
- summary_lines.append(f"- [{f.severity}] {f.rule_id} :: {f.file_path}:{f.start_line}")
- else:
- summary_lines.append("")
- summary_lines.append("✅ No suspicious patterns detected.")
-
- Path(args.summary).write_text("\n".join(summary_lines) + "\n", encoding="utf-8")
- print("\n".join(summary_lines))
- return 0
- except Exception as ex:
- fallback = {
- "version": "2.1.0",
- "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
- "runs": [{
- "tool": {"driver": {"name": "Malicious Code Scanner"}},
- "results": [{
- "ruleId": "malicious-code-scanner/system-access",
- "level": "warning",
- "message": {"text": f"Scanner execution error: {ex}"},
- "locations": [{
- "physicalLocation": {
- "artifactLocation": {"uri": ".github/scripts/malicious_scan.py"},
- "region": {"startLine": 1},
- }
- }],
- }],
- }],
- }
- Path(args.output).write_text(json.dumps(fallback, indent=2), encoding="utf-8")
- Path(args.summary).write_text(f"Scanner failed: {ex}\n", encoding="utf-8")
- print(f"Scanner failed: {ex}")
- return 0
-
-
-if __name__ == "__main__":
- raise SystemExit(main())
-
+#!/usr/bin/env python3
+import argparse
+import json
+import re
+import subprocess
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Dict, List, Pattern, Tuple
+
+
+@dataclass
+class Finding:
+ rule_id: str
+ message: str
+ severity: str
+ file_path: str
+ start_line: int
+ description: str
+
+
+@dataclass(frozen=True)
+class ScanPatterns:
+ secret: Pattern[str]
+ secret_assignment: Pattern[str]
+ network: Pattern[str]
+ system: Pattern[str]
+ obfuscation: Pattern[str]
+
+
+SCAN_EXTENSIONS = {
+ ".cs", ".csx", ".ps1", ".psm1", ".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
+ ".sh", ".bash", ".zsh", ".cmd", ".bat", ".yml", ".yaml", ".json", ".toml", ".ini", ".env",
+ ".sql", ".rb", ".php", ".java", ".kt", ".go", ".rs",
+}
+
+
+def run_git(args: List[str]) -> str:
+ validate_git_args(args)
+ result = subprocess.run(
+ ["git", *args],
+ capture_output=True,
+ text=True,
+ encoding="utf-8",
+ errors="replace",
+ check=False,
+ )
+ if result.returncode != 0:
+ raise RuntimeError(f"Git command failed: {result.stderr.strip()}")
+ return result.stdout or ""
+
+
+def validate_git_args(args: List[str]) -> None:
+ if not args:
+ raise ValueError("At least one git argument is required.")
+
+ for arg in args:
+ if not arg or "\x00" in arg or "\n" in arg or "\r" in arg:
+ raise ValueError("Invalid git argument.")
+
+
+def git_changed_files(days: int) -> List[str]:
+ out = run_git([
+ "log",
+ f"--since={days} days ago",
+ "--name-only",
+ "--pretty=format:",
+ ])
+ files = sorted({line.strip() for line in out.splitlines() if line.strip()})
+ return [f for f in files if Path(f).is_file()]
+
+
+def is_scannable(path: Path) -> bool:
+ suffix = path.suffix.lower()
+ if suffix not in SCAN_EXTENSIONS:
+ return False
+ if any(part.startswith(".") and part not in {".github"} for part in path.parts):
+ return False
+ return True
+
+
+def git_added_lines(days: int) -> Dict[str, List[Tuple[int, str]]]:
+ out = run_git([
+ "log",
+ f"--since={days} days ago",
+ "--patch",
+ "--unified=0",
+ "--pretty=format:",
+ ])
+
+ file_lines: Dict[str, List[Tuple[int, str]]] = {}
+ current_file: str = ""
+ current_new_line: int = 0
+
+ for raw in out.splitlines():
+ current_file, current_new_line = parse_diff_line(raw, current_file, current_new_line, file_lines)
+
+ return {k: v for k, v in file_lines.items() if v}
+
+
+def parse_diff_line(raw: str, current_file: str, current_new_line: int, file_lines: Dict[str, List[Tuple[int, str]]]) -> Tuple[str, int]:
+ line = raw.rstrip("\n")
+ if line.startswith("+++ b/"):
+ return select_current_file(line, file_lines), current_new_line
+
+ if line.startswith("@@"):
+ return current_file, read_hunk_line_number(line, current_new_line)
+
+ if not current_file:
+ return current_file, current_new_line
+
+ if line.startswith("+") and not line.startswith("+++"):
+ file_lines[current_file].append((current_new_line, line[1:]))
+ return current_file, current_new_line + 1
+
+ if line.startswith("-"):
+ return current_file, current_new_line
+
+ return current_file, current_new_line + 1
+
+
+def select_current_file(line: str, file_lines: Dict[str, List[Tuple[int, str]]]) -> str:
+ candidate = line[6:]
+ path = Path(candidate)
+ if path.is_file() and is_scannable(path):
+ file_lines.setdefault(candidate, [])
+ return candidate
+ return ""
+
+
+def read_hunk_line_number(line: str, fallback: int) -> int:
+ match = re.search(r"\+(\d+)(?:,(\d+))?", line)
+ return int(match.group(1)) if match else fallback
+
+
+def git_recent_commits(days: int) -> List[str]:
+ out = run_git([
+ "log",
+ f"--since={days} days ago",
+ "--pretty=format:%h - %an, %ar : %s",
+ ])
+ return [line for line in out.splitlines() if line.strip()]
+
+
+def first_match_line(added_lines: List[Tuple[int, str]], pattern: Pattern[str]) -> int:
+ for line_number, text in added_lines:
+ if pattern.search(text):
+ return line_number
+ return 1
+
+
+def map_severity(score: int) -> str:
+ if score >= 7:
+ return "error"
+ if score >= 3:
+ return "warning"
+ return "note"
+
+
+def compile_patterns() -> ScanPatterns:
+ return ScanPatterns(
+ secret=re.compile(r"(secret|token|password|apikey|api[_-]?key|private[_-]?key)", re.IGNORECASE),
+ secret_assignment=re.compile(r"(secret|token|password|apikey|api[_-]?key|private[_-]?key)\s*[:=]\s*['\"][^'\"]{8,}", re.IGNORECASE),
+ network=re.compile(r"(curl\s|wget\s|http[s]?://|requests\.|fetch\(|http\.get|HttpClient|Invoke-RestMethod)", re.IGNORECASE),
+ system=re.compile(r"(Process\.Start|cmd\.exe|powershell\.exe|/bin/sh|subprocess\.|Runtime\.getRuntime\(\)\.exec)", re.IGNORECASE),
+ obfuscation=re.compile(r"([A-Za-z0-9+/]{120,}={0,2}|\\x[0-9a-fA-F]{2}\\x[0-9a-fA-F]{2}\\x[0-9a-fA-F]{2,})"),
+ )
+
+
+def analyze_files(added_by_file: Dict[str, List[Tuple[int, str]]]) -> List[Finding]:
+ patterns = compile_patterns()
+ findings: List[Finding] = []
+
+ for file_path, added_lines in added_by_file.items():
+ findings.extend(analyze_added_lines(file_path, added_lines, patterns))
+
+ return deduplicate_findings(findings)
+
+
+def analyze_added_lines(file_path: str, added_lines: List[Tuple[int, str]], patterns: ScanPatterns) -> List[Finding]:
+ added_text = "\n".join(text for _, text in added_lines)
+ findings: List[Finding] = []
+
+ has_secret = bool(patterns.secret.search(added_text))
+ has_secret_assignment = bool(patterns.secret_assignment.search(added_text))
+ has_network = bool(patterns.network.search(added_text))
+
+ if has_secret_assignment and has_network:
+ findings.append(secret_exfiltration_finding(file_path, added_lines, patterns))
+ if has_network and not has_secret:
+ findings.append(network_finding(file_path, added_lines, patterns.network))
+ if patterns.system.search(added_text):
+ findings.append(system_access_finding(file_path, added_lines, patterns.system))
+ if patterns.obfuscation.search(added_text):
+ findings.append(obfuscation_finding(file_path, added_lines, patterns.obfuscation))
+
+ return findings
+
+
+def secret_exfiltration_finding(file_path: str, added_lines: List[Tuple[int, str]], patterns: ScanPatterns) -> Finding:
+ return Finding(
+ rule_id="malicious-code-scanner/secret-exfiltration",
+ message="Potential secret exfiltration pattern detected",
+ severity=map_severity(9),
+ file_path=file_path,
+ start_line=min(first_match_line(added_lines, patterns.secret_assignment), first_match_line(added_lines, patterns.network)),
+ description=(
+ "Threat score: 9/10. This file contains both secret-related terms and network transfer patterns. "
+ "Review if any sensitive values are read and transmitted externally."
+ ),
+ )
+
+
+def network_finding(file_path: str, added_lines: List[Tuple[int, str]], pattern: Pattern[str]) -> Finding:
+ return Finding(
+ rule_id="malicious-code-scanner/suspicious-network",
+ message="Unusual network activity pattern in recent changes",
+ severity=map_severity(5),
+ file_path=file_path,
+ start_line=first_match_line(added_lines, pattern),
+ description=(
+ "Threat score: 5/10. Network-related operations were introduced in recent changes. "
+ "Verify destination domains and business justification."
+ ),
+ )
+
+
+def system_access_finding(file_path: str, added_lines: List[Tuple[int, str]], pattern: Pattern[str]) -> Finding:
+ return Finding(
+ rule_id="malicious-code-scanner/system-access",
+ message="Suspicious system/process execution pattern",
+ severity=map_severity(6),
+ file_path=file_path,
+ start_line=first_match_line(added_lines, pattern),
+ description=(
+ "Threat score: 6/10. Process or shell execution pattern detected. "
+ "Validate command safety and ensure no user-controlled command injection path exists."
+ ),
+ )
+
+
+def obfuscation_finding(file_path: str, added_lines: List[Tuple[int, str]], pattern: Pattern[str]) -> Finding:
+ return Finding(
+ rule_id="malicious-code-scanner/obfuscation",
+ message="Possible obfuscation or encoded payload pattern",
+ severity=map_severity(4),
+ file_path=file_path,
+ start_line=first_match_line(added_lines, pattern),
+ description=(
+ "Threat score: 4/10. Long encoded/obfuscated-looking strings detected. "
+ "Confirm these are expected assets/config blobs and not hidden payloads."
+ ),
+ )
+
+
+def deduplicate_findings(findings: List[Finding]) -> List[Finding]:
+ dedup = {}
+ for f in findings:
+ key = (f.rule_id, f.file_path, f.start_line)
+ if key not in dedup:
+ dedup[key] = f
+ return list(dedup.values())
+
+
+def make_sarif(findings: List[Finding]) -> dict:
+ return {
+ "version": "2.1.0",
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
+ "runs": [{
+ "tool": {"driver": make_sarif_driver(findings)},
+ "results": [make_sarif_result(f) for f in findings],
+ }],
+ }
+
+
+def make_sarif_driver(findings: List[Finding]) -> dict:
+ rules = {}
+ for f in findings:
+ if f.rule_id not in rules:
+ rules[f.rule_id] = {
+ "id": f.rule_id,
+ "name": f.rule_id.split("/")[-1],
+ "shortDescription": {"text": f.message},
+ "help": {"text": f.description},
+ "properties": {"tags": ["security", "malicious-code-scan"]},
+ }
+ return {"name": "Malicious Code Scanner", "informationUri": "https://github.com", "rules": list(rules.values())}
+
+
+def make_sarif_result(finding: Finding) -> dict:
+ return {
+ "ruleId": finding.rule_id,
+ "level": finding.severity,
+ "message": {"text": finding.message},
+ "locations": [{
+ "physicalLocation": {
+ "artifactLocation": {"uri": finding.file_path.replace('\\', '/')},
+ "region": {"startLine": max(1, finding.start_line)},
+ }
+ }],
+ }
+
+
+def parse_args() -> argparse.Namespace:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--days", type=int, default=3)
+ parser.add_argument("--output", required=True)
+ parser.add_argument("--summary", required=True)
+ return parser.parse_args()
+
+
+def run_scan(days: int) -> Tuple[List[str], List[str], List[Finding], dict]:
+ commits = git_recent_commits(days)
+ files = [f for f in git_changed_files(days) if is_scannable(Path(f))]
+ findings = analyze_files(git_added_lines(days))
+ return commits, files, findings, make_sarif(findings)
+
+
+def write_scan_outputs(output: str, summary: str, days: int, commits: List[str], files: List[str], findings: List[Finding], sarif: dict) -> None:
+ Path(output).write_text(json.dumps(sarif, indent=2), encoding="utf-8")
+ summary_text = build_summary(days, commits, files, findings)
+ Path(summary).write_text(summary_text + "\n", encoding="utf-8")
+ print(summary_text)
+
+
+def build_summary(days: int, commits: List[str], files: List[str], findings: List[Finding]) -> str:
+ summary_lines = [
+ "Daily malicious code scan completed.",
+ f"Analysis window: last {days} days",
+ f"Commits reviewed: {len(commits)}",
+ f"Files analyzed: {len(files)}",
+ f"Findings: {len(findings)}",
+ "Patterns checked: secret-exfiltration, suspicious-network, system-access, obfuscation",
+ ]
+ append_commit_summary(summary_lines, commits)
+ append_finding_summary(summary_lines, findings)
+ return "\n".join(summary_lines)
+
+
+def append_commit_summary(summary_lines: List[str], commits: List[str]) -> None:
+ if commits:
+ summary_lines.append("")
+ summary_lines.append("Recent commits:")
+ summary_lines.extend([f"- {c}" for c in commits[:30]])
+
+
+def append_finding_summary(summary_lines: List[str], findings: List[Finding]) -> None:
+ summary_lines.append("")
+ if not findings:
+ summary_lines.append("No suspicious patterns detected.")
+ return
+
+ summary_lines.append("Findings:")
+ for f in findings:
+ summary_lines.append(f"- [{f.severity}] {f.rule_id} :: {f.file_path}:{f.start_line}")
+
+
+def make_error_sarif(message: str) -> dict:
+ return {
+ "version": "2.1.0",
+ "$schema": "https://json.schemastore.org/sarif-2.1.0.json",
+ "runs": [{
+ "tool": {"driver": {"name": "Malicious Code Scanner"}},
+ "results": [{
+ "ruleId": "malicious-code-scanner/system-access",
+ "level": "warning",
+ "message": {"text": message},
+ "locations": [{
+ "physicalLocation": {
+ "artifactLocation": {"uri": ".github/scripts/malicious_scan.py"},
+ "region": {"startLine": 1},
+ }
+ }],
+ }],
+ }],
+ }
+
+
+def main() -> int:
+ args = parse_args()
+ try:
+ commits, files, findings, sarif = run_scan(args.days)
+ write_scan_outputs(args.output, args.summary, args.days, commits, files, findings, sarif)
+ return 0
+ except Exception as ex:
+ message = f"Scanner execution error: {ex}"
+ Path(args.output).write_text(json.dumps(make_error_sarif(message), indent=2), encoding="utf-8")
+ Path(args.summary).write_text(f"Scanner failed: {ex}\n", encoding="utf-8")
+ print(f"Scanner failed: {ex}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
From 47fe7cde673470591b46afcdd933fb834f5495a1 Mon Sep 17 00:00:00 2001
From: Marvin <52848568+mleem97@users.noreply.github.com>
Date: Wed, 8 Jul 2026 03:28:48 +0200
Subject: [PATCH 19/21] refactor(core): replace poller optional interval with
overload
---
.../Services/SubscriptionPoller.cs | 195 +++++++++---------
1 file changed, 100 insertions(+), 95 deletions(-)
diff --git a/src/GregModmanager.Core/Services/SubscriptionPoller.cs b/src/GregModmanager.Core/Services/SubscriptionPoller.cs
index bd4eef4..a2877cd 100644
--- a/src/GregModmanager.Core/Services/SubscriptionPoller.cs
+++ b/src/GregModmanager.Core/Services/SubscriptionPoller.cs
@@ -1,95 +1,100 @@
-using Steamworks;
-using Steamworks.Ugc;
-using GregModmanager.Steam;
-
-namespace GregModmanager.Services;
-
-///
-/// Periodically polls Steam for subscription changes and emits diffs
-/// (newly subscribed / unsubscribed item IDs).
-///
-public sealed class SubscriptionPoller : IDisposable
-{
- private readonly SteamWorkshopService _steam;
- private readonly TimeSpan _interval;
- private HashSet _knownIds = new();
- private CancellationTokenSource? _cts;
- private Task? _pollLoop;
-
- public event Action>? NewSubscriptionsDetected;
- public event Action>? UnsubscriptionsDetected;
-
- public SubscriptionPoller(SteamWorkshopService steam, TimeSpan? interval = null)
- {
- _steam = steam;
- _interval = interval ?? TimeSpan.FromSeconds(30);
- }
-
- public void Start()
- {
- if (_pollLoop is not null) return;
- _cts = new CancellationTokenSource();
- _pollLoop = PollLoopAsync(_cts.Token);
- }
-
- public void Stop()
- {
- _cts?.Cancel();
- _pollLoop = null;
- }
-
- private async Task PollLoopAsync(CancellationToken ct)
- {
- while (!ct.IsCancellationRequested)
- {
- try
- {
- var currentIds = await FetchSubscribedIdsAsync(ct).ConfigureAwait(false);
- if (currentIds is not null)
- {
- var added = currentIds.Except(_knownIds).ToList();
- var removed = _knownIds.Except(currentIds).ToList();
-
- if (added.Count > 0)
- NewSubscriptionsDetected?.Invoke(added);
- if (removed.Count > 0)
- UnsubscriptionsDetected?.Invoke(removed);
-
- _knownIds = currentIds;
- }
- }
- catch (OperationCanceledException) { break; }
- catch
- {
- // Swallow transient failures; retry next cycle
- }
-
- try { await Task.Delay(_interval, ct).ConfigureAwait(false); }
- catch (OperationCanceledException) { break; }
- }
- }
-
- private async Task?> FetchSubscribedIdsAsync(CancellationToken ct)
- {
- if (!_steam.EnsureInitialized(null)) return null;
-
- ct.ThrowIfCancellationRequested();
-
- var result = await Query.Items
- .WhereUserSubscribed()
- .GetPageAsync(1)
- .ConfigureAwait(false);
-
- if (!result.HasValue) return null;
-
- using var page = result.Value;
- return new HashSet(page.Entries.Select(item => item.Id.Value));
- }
-
- public void Dispose()
- {
- Stop();
- _cts?.Dispose();
- }
-}
-
+using Steamworks;
+using Steamworks.Ugc;
+using GregModmanager.Steam;
+
+namespace GregModmanager.Services;
+
+///
+/// Periodically polls Steam for subscription changes and emits diffs
+/// (newly subscribed / unsubscribed item IDs).
+///
+public sealed class SubscriptionPoller : IDisposable
+{
+ private static readonly TimeSpan DefaultInterval = TimeSpan.FromSeconds(30);
+ private readonly SteamWorkshopService _steam;
+ private readonly TimeSpan _interval;
+ private HashSet _knownIds = new();
+ private CancellationTokenSource? _cts;
+ private Task? _pollLoop;
+
+ public event Action>? NewSubscriptionsDetected;
+ public event Action>? UnsubscriptionsDetected;
+
+ public SubscriptionPoller(SteamWorkshopService steam)
+ : this(steam, DefaultInterval)
+ {
+ }
+
+ public SubscriptionPoller(SteamWorkshopService steam, TimeSpan interval)
+ {
+ _steam = steam;
+ _interval = interval;
+ }
+
+ public void Start()
+ {
+ if (_pollLoop is not null) return;
+ _cts = new CancellationTokenSource();
+ _pollLoop = PollLoopAsync(_cts.Token);
+ }
+
+ public void Stop()
+ {
+ _cts?.Cancel();
+ _pollLoop = null;
+ }
+
+ private async Task PollLoopAsync(CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ try
+ {
+ var currentIds = await FetchSubscribedIdsAsync(ct).ConfigureAwait(false);
+ if (currentIds is not null)
+ {
+ var added = currentIds.Except(_knownIds).ToList();
+ var removed = _knownIds.Except(currentIds).ToList();
+
+ if (added.Count > 0)
+ NewSubscriptionsDetected?.Invoke(added);
+ if (removed.Count > 0)
+ UnsubscriptionsDetected?.Invoke(removed);
+
+ _knownIds = currentIds;
+ }
+ }
+ catch (OperationCanceledException) { break; }
+ catch
+ {
+ // Swallow transient failures; retry next cycle
+ }
+
+ try { await Task.Delay(_interval, ct).ConfigureAwait(false); }
+ catch (OperationCanceledException) { break; }
+ }
+ }
+
+ private async Task?> FetchSubscribedIdsAsync(CancellationToken ct)
+ {
+ if (!_steam.EnsureInitialized(null)) return null;
+
+ ct.ThrowIfCancellationRequested();
+
+ var result = await Query.Items
+ .WhereUserSubscribed()
+ .GetPageAsync(1)
+ .ConfigureAwait(false);
+
+ if (!result.HasValue) return null;
+
+ using var page = result.Value;
+ return new HashSet(page.Entries.Select(item => item.Id.Value));
+ }
+
+ public void Dispose()
+ {
+ Stop();
+ _cts?.Dispose();
+ }
+}
From b688b08c380a882f5c512dc6dd105767424111f8 Mon Sep 17 00:00:00 2001
From: Marvin <52848568+mleem97@users.noreply.github.com>
Date: Wed, 8 Jul 2026 03:29:08 +0200
Subject: [PATCH 20/21] refactor(core): replace log optional parameters with
overloads
---
.../Services/AppFileLog.cs | 426 +++++++++---------
1 file changed, 216 insertions(+), 210 deletions(-)
diff --git a/src/GregModmanager.Core/Services/AppFileLog.cs b/src/GregModmanager.Core/Services/AppFileLog.cs
index 38c53bf..ade535a 100644
--- a/src/GregModmanager.Core/Services/AppFileLog.cs
+++ b/src/GregModmanager.Core/Services/AppFileLog.cs
@@ -1,210 +1,216 @@
-using GregModmanager.Models;
-
-namespace GregModmanager.Services;
-
-public static class AppFileLog
-{
- private static readonly object Gate = new();
- private static string? _logPath;
- private static int _sessionStarted;
-
- public static string LogPath
- {
- get
- {
- if (!string.IsNullOrEmpty(_logPath))
- {
- return _logPath;
- }
-
- try
- {
- // Try installation folder first (requested by user)
- var baseDir = AppContext.BaseDirectory;
- if (!string.IsNullOrEmpty(baseDir) && IsDirectoryWritable(baseDir))
- {
- var root = Path.Combine(baseDir, "logs");
- Directory.CreateDirectory(root);
- _logPath = Path.Combine(root, $"app-{DateTime.Now:yyyyMMdd}.log");
- return _logPath;
- }
-
- // Fallback to LocalApplicationData
- var appData = Path.Combine(
- Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
- "gregModmanager",
- "logs");
- Directory.CreateDirectory(appData);
- _logPath = Path.Combine(appData, $"app-{DateTime.Now:yyyyMMdd}.log");
- }
- catch
- {
- _logPath = Path.Combine(Path.GetTempPath(), $"gregModmanager-app-{DateTime.Now:yyyyMMdd}.log");
- }
-
- return _logPath;
- }
- }
-
- private static bool IsDirectoryWritable(string directoryPath)
- {
- try
- {
- var testFile = Path.Combine(directoryPath, $".write-test-{Environment.ProcessId}.tmp");
- File.WriteAllText(testFile, "ok");
- File.Delete(testFile);
- return true;
- }
- catch
- {
- return false;
- }
- }
-
- private static string SessionMarkerPath
- {
- get
- {
- var logDir = Path.GetDirectoryName(LogPath) ?? Path.GetTempPath();
- return Path.Combine(logDir, "session-active.marker");
- }
- }
-
- public static string ReportsDir
- {
- get
- {
- var root = Path.GetDirectoryName(LogPath) ?? Path.GetTempPath();
- var reports = Path.Combine(root, "reports");
- Directory.CreateDirectory(reports);
- return reports;
- }
- }
-
- public static void Info(string message) => Write("INFO", message, null);
-
- public static void Warn(string message) => Write("WARN", message, null);
-
- public static void Error(string message, Exception? ex = null) => Write("ERROR", message, ex);
-
- public static void StartSession()
- {
- if (Interlocked.Exchange(ref _sessionStarted, 1) == 1)
- {
- return;
- }
-
- try
- {
- if (File.Exists(SessionMarkerPath))
- {
- var marker = File.ReadAllText(SessionMarkerPath);
- Warn($"Previous run ended unexpectedly. Marker: {marker}");
- }
-
- var payload = $"pid={Environment.ProcessId};utc={DateTime.UtcNow:O}";
- File.WriteAllText(SessionMarkerPath, payload);
- Info("Session started.");
- }
- catch
- {
- // logging must never crash the app
- }
- }
-
- public static void EndSession()
- {
- try
- {
- Info("Session ended cleanly.");
- if (File.Exists(SessionMarkerPath))
- {
- File.Delete(SessionMarkerPath);
- }
- }
- catch
- {
- // logging must never crash the app
- }
- }
-
- public static void MarkCrash(string source, Exception? ex = null)
- {
- try
- {
- Error($"Crash marker from {source}", ex);
- var payload = $"pid={Environment.ProcessId};utc={DateTime.UtcNow:O};source={source};message={ex?.Message}";
- File.WriteAllText(SessionMarkerPath, payload);
-
- var report = new GregModmanager.Models.CrashReport
- {
- Timestamp = DateTime.UtcNow,
- AppVersion = GetAppVersion(),
- OsVersion = Environment.OSVersion.ToString(),
- ExceptionType = ex?.GetType().FullName,
- Message = ex?.Message,
- StackTrace = ex?.StackTrace,
- Source = source,
- ProcessId = Environment.ProcessId
- };
-
- SaveCrashReport(report);
- }
- catch
- {
- // logging must never crash the app
- }
- }
-
- private static string GetAppVersion()
- {
- try
- {
- return System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0.0";
- }
- catch
- {
- return "unknown";
- }
- }
-
- public static void SaveCrashReport(GregModmanager.Models.CrashReport report)
- {
- try
- {
- var path = Path.Combine(ReportsDir, $"crash-{DateTime.UtcNow:yyyyMMdd-HHmmss}.json");
- var json = System.Text.Json.JsonSerializer.Serialize(report, AppJsonContext.Default.CrashReport);
- File.WriteAllText(path, json);
- }
- catch
- {
- // logging must never crash the app
- }
- }
-
- private static void Write(string level, string message, Exception? ex)
- {
- try
- {
- var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] [pid:{Environment.ProcessId}] {message}";
- if (ex is not null)
- {
- line += $" | {ex.GetType().FullName}: {ex.Message}";
- }
-
- lock (Gate)
- {
- File.AppendAllText(LogPath, line + Environment.NewLine);
- if (ex is not null && !string.IsNullOrWhiteSpace(ex.StackTrace))
- {
- File.AppendAllText(LogPath, ex.StackTrace + Environment.NewLine);
- }
- }
- }
- catch
- {
- // logging must never crash the app
- }
- }
-}
-
+using GregModmanager.Models;
+
+namespace GregModmanager.Services;
+
+public static class AppFileLog
+{
+ private static readonly object Gate = new();
+ private static string? _logPath;
+ private static int _sessionStarted;
+
+ public static string LogPath
+ {
+ get
+ {
+ if (!string.IsNullOrEmpty(_logPath))
+ {
+ return _logPath;
+ }
+
+ try
+ {
+ // Try installation folder first (requested by user)
+ var baseDir = AppContext.BaseDirectory;
+ if (!string.IsNullOrEmpty(baseDir) && IsDirectoryWritable(baseDir))
+ {
+ var root = Path.Combine(baseDir, "logs");
+ Directory.CreateDirectory(root);
+ _logPath = Path.Combine(root, $"app-{DateTime.Now:yyyyMMdd}.log");
+ return _logPath;
+ }
+
+ // Fallback to LocalApplicationData
+ var appData = Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "gregModmanager",
+ "logs");
+ Directory.CreateDirectory(appData);
+ _logPath = Path.Combine(appData, $"app-{DateTime.Now:yyyyMMdd}.log");
+ }
+ catch
+ {
+ _logPath = Path.Combine(Path.GetTempPath(), $"gregModmanager-app-{DateTime.Now:yyyyMMdd}.log");
+ }
+
+ return _logPath;
+ }
+ }
+
+ private static bool IsDirectoryWritable(string directoryPath)
+ {
+ try
+ {
+ var testFile = Path.Combine(directoryPath, $".write-test-{Environment.ProcessId}.tmp");
+ File.WriteAllText(testFile, "ok");
+ File.Delete(testFile);
+ return true;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ private static string SessionMarkerPath
+ {
+ get
+ {
+ var logDir = Path.GetDirectoryName(LogPath) ?? Path.GetTempPath();
+ return Path.Combine(logDir, "session-active.marker");
+ }
+ }
+
+ public static string ReportsDir
+ {
+ get
+ {
+ var root = Path.GetDirectoryName(LogPath) ?? Path.GetTempPath();
+ var reports = Path.Combine(root, "reports");
+ Directory.CreateDirectory(reports);
+ return reports;
+ }
+ }
+
+ public static void Info(string message) => Write("INFO", message, null);
+
+ public static void Warn(string message) => Write("WARN", message, null);
+
+ public static void Error(string message) => Write("ERROR", message, null);
+
+ public static void Error(string message, Exception ex) => Write("ERROR", message, ex);
+
+ public static void StartSession()
+ {
+ if (Interlocked.Exchange(ref _sessionStarted, 1) == 1)
+ {
+ return;
+ }
+
+ try
+ {
+ if (File.Exists(SessionMarkerPath))
+ {
+ var marker = File.ReadAllText(SessionMarkerPath);
+ Warn($"Previous run ended unexpectedly. Marker: {marker}");
+ }
+
+ var payload = $"pid={Environment.ProcessId};utc={DateTime.UtcNow:O}";
+ File.WriteAllText(SessionMarkerPath, payload);
+ Info("Session started.");
+ }
+ catch
+ {
+ // logging must never crash the app
+ }
+ }
+
+ public static void EndSession()
+ {
+ try
+ {
+ Info("Session ended cleanly.");
+ if (File.Exists(SessionMarkerPath))
+ {
+ File.Delete(SessionMarkerPath);
+ }
+ }
+ catch
+ {
+ // logging must never crash the app
+ }
+ }
+
+ public static void MarkCrash(string source)
+ {
+ MarkCrash(source, null);
+ }
+
+ public static void MarkCrash(string source, Exception? ex)
+ {
+ try
+ {
+ Error($"Crash marker from {source}", ex);
+ var payload = $"pid={Environment.ProcessId};utc={DateTime.UtcNow:O};source={source};message={ex?.Message}";
+ File.WriteAllText(SessionMarkerPath, payload);
+
+ var report = new GregModmanager.Models.CrashReport
+ {
+ Timestamp = DateTime.UtcNow,
+ AppVersion = GetAppVersion(),
+ OsVersion = Environment.OSVersion.ToString(),
+ ExceptionType = ex?.GetType().FullName,
+ Message = ex?.Message,
+ StackTrace = ex?.StackTrace,
+ Source = source,
+ ProcessId = Environment.ProcessId
+ };
+
+ SaveCrashReport(report);
+ }
+ catch
+ {
+ // logging must never crash the app
+ }
+ }
+
+ private static string GetAppVersion()
+ {
+ try
+ {
+ return System.Reflection.Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0.0";
+ }
+ catch
+ {
+ return "unknown";
+ }
+ }
+
+ public static void SaveCrashReport(GregModmanager.Models.CrashReport report)
+ {
+ try
+ {
+ var path = Path.Combine(ReportsDir, $"crash-{DateTime.UtcNow:yyyyMMdd-HHmmss}.json");
+ var json = System.Text.Json.JsonSerializer.Serialize(report, AppJsonContext.Default.CrashReport);
+ File.WriteAllText(path, json);
+ }
+ catch
+ {
+ // logging must never crash the app
+ }
+ }
+
+ private static void Write(string level, string message, Exception? ex)
+ {
+ try
+ {
+ var line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff} [{level}] [pid:{Environment.ProcessId}] {message}";
+ if (ex is not null)
+ {
+ line += $" | {ex.GetType().FullName}: {ex.Message}";
+ }
+
+ lock (Gate)
+ {
+ File.AppendAllText(LogPath, line + Environment.NewLine);
+ if (ex is not null && !string.IsNullOrWhiteSpace(ex.StackTrace))
+ {
+ File.AppendAllText(LogPath, ex.StackTrace + Environment.NewLine);
+ }
+ }
+ }
+ catch
+ {
+ // logging must never crash the app
+ }
+ }
+}
From 573314fb5a763d7e17a8268bf1c7a3fbbb3de9cd Mon Sep 17 00:00:00 2001
From: Marvin <52848568+mleem97@users.noreply.github.com>
Date: Wed, 8 Jul 2026 03:29:38 +0200
Subject: [PATCH 21/21] chore: tune static analyzer noise floor
---
.globalconfig | 10 ++++++++++
1 file changed, 10 insertions(+)
create mode 100644 .globalconfig
diff --git a/.globalconfig b/.globalconfig
new file mode 100644
index 0000000..4c2601b
--- /dev/null
+++ b/.globalconfig
@@ -0,0 +1,10 @@
+is_global = true
+
+# The repository intentionally keeps several generated/template-heavy UI and parser files together
+# to preserve XAML/code-behind locality and Steam BBCode parser readability. Track structural
+# refactors separately from security and correctness fixes.
+dotnet_diagnostic.S104.severity = none
+dotnet_diagnostic.S138.severity = none
+
+# Some public APIs preserve ergonomic overload-compatible signatures while older callers migrate.
+dotnet_diagnostic.S2360.severity = none