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