From 5a8d1ac39f4d3f836129a08b9c66083f85841542 Mon Sep 17 00:00:00 2001 From: Fuggschen Date: Mon, 13 Apr 2026 04:56:52 +0200 Subject: [PATCH 01/51] Pushing project files --- .github/workflows/build.yml | 30 + .github/workflows/release.yml | 56 ++ .gitignore | 29 + DVModManager.slnx | 3 + DVModManager/App.axaml | 27 + DVModManager/App.axaml.cs | 116 +++ .../Converters/InverseBoolConverter.cs | 15 + .../Converters/ModStateToBrushConverter.cs | 27 + .../Converters/NullToBoolConverter.cs | 25 + DVModManager/DVModManager.csproj | 36 + DVModManager/Helpers/PlatformHelper.cs | 29 + DVModManager/Models/AppSettings.cs | 32 + DVModManager/Models/ModInfo.cs | 36 + DVModManager/Models/ModProfile.cs | 16 + DVModManager/Models/ModState.cs | 10 + DVModManager/Models/ModUpdateInfo.cs | 16 + DVModManager/Models/ModVersion.cs | 16 + DVModManager/Program.cs | 8 + DVModManager/Services/DialogService.cs | 161 ++++ DVModManager/Services/FileLoggerProvider.cs | 93 +++ DVModManager/Services/GameDetectionService.cs | 119 +++ DVModManager/Services/GitHubModsService.cs | 219 ++++++ DVModManager/Services/IDialogService.cs | 13 + .../Services/IGameDetectionService.cs | 11 + DVModManager/Services/IGitHubModsService.cs | 9 + DVModManager/Services/IModDiscoveryService.cs | 12 + DVModManager/Services/IModInstallService.cs | 14 + DVModManager/Services/INexusModsService.cs | 10 + DVModManager/Services/IProfileService.cs | 23 + DVModManager/Services/ISettingsService.cs | 10 + DVModManager/Services/IUpdateService.cs | 9 + DVModManager/Services/IVersionCacheService.cs | 19 + DVModManager/Services/ModDiscoveryService.cs | 131 +++ DVModManager/Services/ModInstallService.cs | 451 +++++++++++ DVModManager/Services/NexusModsService.cs | 134 ++++ DVModManager/Services/ProfileService.cs | 109 +++ DVModManager/Services/SettingsService.cs | 41 + DVModManager/Services/UpdateService.cs | 45 ++ DVModManager/Services/VersionCacheService.cs | 122 +++ .../ViewModels/MainWindowViewModel.cs | 743 ++++++++++++++++++ DVModManager/ViewModels/ModItemViewModel.cs | 84 ++ DVModManager/ViewModels/ProfileViewModel.cs | 95 +++ DVModManager/ViewModels/SettingsViewModel.cs | 107 +++ DVModManager/ViewModels/ViewModelBase.cs | 5 + DVModManager/Views/MainWindow.axaml | 226 ++++++ DVModManager/Views/MainWindow.axaml.cs | 17 + DVModManager/Views/ModDetailPanel.axaml | 198 +++++ DVModManager/Views/ModDetailPanel.axaml.cs | 21 + DVModManager/Views/ModListView.axaml | 94 +++ DVModManager/Views/ModListView.axaml.cs | 52 ++ DVModManager/Views/ProfileDialog.axaml | 126 +++ DVModManager/Views/ProfileDialog.axaml.cs | 11 + DVModManager/Views/SettingsDialog.axaml | 188 +++++ DVModManager/Views/SettingsDialog.axaml.cs | 11 + 54 files changed, 4260 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 DVModManager.slnx create mode 100644 DVModManager/App.axaml create mode 100644 DVModManager/App.axaml.cs create mode 100644 DVModManager/Converters/InverseBoolConverter.cs create mode 100644 DVModManager/Converters/ModStateToBrushConverter.cs create mode 100644 DVModManager/Converters/NullToBoolConverter.cs create mode 100644 DVModManager/DVModManager.csproj create mode 100644 DVModManager/Helpers/PlatformHelper.cs create mode 100644 DVModManager/Models/AppSettings.cs create mode 100644 DVModManager/Models/ModInfo.cs create mode 100644 DVModManager/Models/ModProfile.cs create mode 100644 DVModManager/Models/ModState.cs create mode 100644 DVModManager/Models/ModUpdateInfo.cs create mode 100644 DVModManager/Models/ModVersion.cs create mode 100644 DVModManager/Program.cs create mode 100644 DVModManager/Services/DialogService.cs create mode 100644 DVModManager/Services/FileLoggerProvider.cs create mode 100644 DVModManager/Services/GameDetectionService.cs create mode 100644 DVModManager/Services/GitHubModsService.cs create mode 100644 DVModManager/Services/IDialogService.cs create mode 100644 DVModManager/Services/IGameDetectionService.cs create mode 100644 DVModManager/Services/IGitHubModsService.cs create mode 100644 DVModManager/Services/IModDiscoveryService.cs create mode 100644 DVModManager/Services/IModInstallService.cs create mode 100644 DVModManager/Services/INexusModsService.cs create mode 100644 DVModManager/Services/IProfileService.cs create mode 100644 DVModManager/Services/ISettingsService.cs create mode 100644 DVModManager/Services/IUpdateService.cs create mode 100644 DVModManager/Services/IVersionCacheService.cs create mode 100644 DVModManager/Services/ModDiscoveryService.cs create mode 100644 DVModManager/Services/ModInstallService.cs create mode 100644 DVModManager/Services/NexusModsService.cs create mode 100644 DVModManager/Services/ProfileService.cs create mode 100644 DVModManager/Services/SettingsService.cs create mode 100644 DVModManager/Services/UpdateService.cs create mode 100644 DVModManager/Services/VersionCacheService.cs create mode 100644 DVModManager/ViewModels/MainWindowViewModel.cs create mode 100644 DVModManager/ViewModels/ModItemViewModel.cs create mode 100644 DVModManager/ViewModels/ProfileViewModel.cs create mode 100644 DVModManager/ViewModels/SettingsViewModel.cs create mode 100644 DVModManager/ViewModels/ViewModelBase.cs create mode 100644 DVModManager/Views/MainWindow.axaml create mode 100644 DVModManager/Views/MainWindow.axaml.cs create mode 100644 DVModManager/Views/ModDetailPanel.axaml create mode 100644 DVModManager/Views/ModDetailPanel.axaml.cs create mode 100644 DVModManager/Views/ModListView.axaml create mode 100644 DVModManager/Views/ModListView.axaml.cs create mode 100644 DVModManager/Views/ProfileDialog.axaml create mode 100644 DVModManager/Views/ProfileDialog.axaml.cs create mode 100644 DVModManager/Views/SettingsDialog.axaml create mode 100644 DVModManager/Views/SettingsDialog.axaml.cs diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..08bb9c7 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,30 @@ +name: Build + +on: + push: + branches: [main, beta] + pull_request: + branches: [main, beta] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + rid: [win-x64, linux-x64] + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Publish (${{ matrix.rid }}) + run: dotnet publish DVModManager/DVModManager.csproj -c Release -r ${{ matrix.rid }} -o publish/${{ matrix.rid }} + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: DVModManager-${{ matrix.rid }} + path: publish/${{ matrix.rid }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4d6095d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,56 @@ +name: Release + +on: + push: + tags: ['v*'] + +env: + IS_BETA: ${{ contains(github.ref, '-beta') }} + +permissions: + contents: write + +jobs: + publish: + runs-on: ubuntu-latest + strategy: + matrix: + rid: [win-x64, linux-x64] + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 8.0.x + + - name: Publish (${{ matrix.rid }}) + run: dotnet publish DVModManager/DVModManager.csproj -c Release -r ${{ matrix.rid }} -o publish/${{ matrix.rid }} + + - name: Package + run: | + cd publish/${{ matrix.rid }} + zip -r ../../DVModManager-${{ matrix.rid }}.zip . + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: DVModManager-${{ matrix.rid }} + path: DVModManager-${{ matrix.rid }}.zip + + release: + needs: publish + runs-on: ubuntu-latest + steps: + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: artifacts + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + draft: ${{ env.IS_BETA == 'true' }} + prerelease: ${{ env.IS_BETA == 'true' }} + files: artifacts/**/*.zip diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..683dcdd --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +## Build results +[Bb]in/ +[Oo]bj/ +publish/ + +## User-specific files +*.user +*.suo +*.userosscache +*.sln.docstates + +## VS Code +.vscode/ + +## Visual Studio +.vs/ + +## Rider +.idea/ + +## NuGet +*.nupkg +**/packages/ + +## OS files +Thumbs.db +ehthumbs.db +Desktop.ini +.DS_Store diff --git a/DVModManager.slnx b/DVModManager.slnx new file mode 100644 index 0000000..e5eacba --- /dev/null +++ b/DVModManager.slnx @@ -0,0 +1,3 @@ + + + diff --git a/DVModManager/App.axaml b/DVModManager/App.axaml new file mode 100644 index 0000000..274139d --- /dev/null +++ b/DVModManager/App.axaml @@ -0,0 +1,27 @@ + + + + + + + + #2ecc71 + #f39c12 + #e74c3c + #7f8c8d + + + + + + + + + + + + + diff --git a/DVModManager/App.axaml.cs b/DVModManager/App.axaml.cs new file mode 100644 index 0000000..e470936 --- /dev/null +++ b/DVModManager/App.axaml.cs @@ -0,0 +1,116 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using DVModManager.Services; +using DVModManager.ViewModels; +using DVModManager.Views; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace DVModManager; + +public class App : Application +{ + public static IServiceProvider Services { get; private set; } = null!; + + public override void Initialize() + { + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted() + { + var services = new ServiceCollection(); + ConfigureServices(services); + Services = services.BuildServiceProvider(); + + // Catch unhandled exceptions on any thread and surface them in the status bar / console + var logDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "DVModManager", "logs"); + AppDomain.CurrentDomain.UnhandledException += (_, e) => + { + var ex = e.ExceptionObject as Exception; + var text = ex?.ToString() ?? e.ExceptionObject?.ToString() ?? "Unknown error"; + var stamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); + var entry = $"[{stamp}] [FATAL] Unhandled exception (terminating={e.IsTerminating}):{Environment.NewLine}{text}{Environment.NewLine}"; + try + { + Directory.CreateDirectory(logDir); + File.AppendAllText(Path.Combine(logDir, $"dvmm-{DateTime.Now:yyyy-MM-dd}.log"), entry); + } + catch { } + Console.Error.WriteLine(entry); + }; + TaskScheduler.UnobservedTaskException += (_, e) => + { + var text = e.Exception.ToString(); + var stamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss"); + var entry = $"[{stamp}] [TASK] Unobserved task exception:{Environment.NewLine}{text}{Environment.NewLine}"; + try + { + Directory.CreateDirectory(logDir); + File.AppendAllText(Path.Combine(logDir, $"dvmm-{DateTime.Now:yyyy-MM-dd}.log"), entry); + } + catch { } + Console.Error.WriteLine(entry); + e.SetObserved(); + }; + + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + { + var vm = Services.GetRequiredService(); + var window = new MainWindow { DataContext = vm }; + + // Provide the window reference to the dialog service + Services.GetRequiredService().SetOwner(window); + + desktop.MainWindow = window; + desktop.ShutdownRequested += (_, _) => + { + Services.GetRequiredService().Dispose(); + Services.GetRequiredService().Dispose(); + }; + } + + base.OnFrameworkInitializationCompleted(); + } + + private static void ConfigureServices(IServiceCollection services) + { + // Logging + var logDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "DVModManager", "logs"); + services.AddLogging(b => + { + b.AddConsole(); + b.AddProvider(new FileLoggerProvider(logDir, LogLevel.Debug)); + b.SetMinimumLevel(LogLevel.Debug); + }); + + // HTTP + services.AddHttpClient(); + + // Core infrastructure + services.AddSingleton(); + services.AddSingleton(); + + // Game & mod services + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Update services + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // ViewModels + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + } +} diff --git a/DVModManager/Converters/InverseBoolConverter.cs b/DVModManager/Converters/InverseBoolConverter.cs new file mode 100644 index 0000000..b32b691 --- /dev/null +++ b/DVModManager/Converters/InverseBoolConverter.cs @@ -0,0 +1,15 @@ +using System.Globalization; +using Avalonia.Data.Converters; + +namespace DVModManager.Converters; + +public class InverseBoolConverter : IValueConverter +{ + public static readonly InverseBoolConverter Instance = new(); + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) => + value is bool b ? !b : value; + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => + value is bool b ? !b : value; +} diff --git a/DVModManager/Converters/ModStateToBrushConverter.cs b/DVModManager/Converters/ModStateToBrushConverter.cs new file mode 100644 index 0000000..25a38c3 --- /dev/null +++ b/DVModManager/Converters/ModStateToBrushConverter.cs @@ -0,0 +1,27 @@ +using System.Globalization; +using Avalonia.Data.Converters; +using Avalonia.Media; +using DVModManager.Models; + +namespace DVModManager.Converters; + +public class ModStateToBrushConverter : IValueConverter +{ + public static readonly ModStateToBrushConverter Instance = new(); + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + return value is ModState state + ? state switch + { + ModState.Active => new SolidColorBrush(Color.Parse("#2ecc71")), + ModState.UpdateAvailable => new SolidColorBrush(Color.Parse("#f39c12")), + ModState.MissingDependency or ModState.NoMetadata => new SolidColorBrush(Color.Parse("#e74c3c")), + _ => new SolidColorBrush(Color.Parse("#7f8c8d")) + } + : Brushes.Gray; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => + throw new NotSupportedException(); +} diff --git a/DVModManager/Converters/NullToBoolConverter.cs b/DVModManager/Converters/NullToBoolConverter.cs new file mode 100644 index 0000000..bc4c6f8 --- /dev/null +++ b/DVModManager/Converters/NullToBoolConverter.cs @@ -0,0 +1,25 @@ +using System.Globalization; +using Avalonia; +using Avalonia.Data.Converters; + +namespace DVModManager.Converters; + +/// +/// Returns true when value is not null. +/// Pass ConverterParameter="invert" to return true when null instead. +/// +public class NullToBoolConverter : IValueConverter +{ + public static readonly NullToBoolConverter Instance = new(); + + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) + { + bool isNotNull = value != null; + if (parameter is string s && s.Equals("invert", StringComparison.OrdinalIgnoreCase)) + return !isNotNull; + return isNotNull; + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) => + throw new NotSupportedException(); +} diff --git a/DVModManager/DVModManager.csproj b/DVModManager/DVModManager.csproj new file mode 100644 index 0000000..6892360 --- /dev/null +++ b/DVModManager/DVModManager.csproj @@ -0,0 +1,36 @@ + + + + WinExe + net8.0 + DVModManager + DVModManager + enable + enable + latest + + true + true + true + true + true + + + + + + + + + + + + + + + + + + + + diff --git a/DVModManager/Helpers/PlatformHelper.cs b/DVModManager/Helpers/PlatformHelper.cs new file mode 100644 index 0000000..df6499a --- /dev/null +++ b/DVModManager/Helpers/PlatformHelper.cs @@ -0,0 +1,29 @@ +using System.Diagnostics; + +namespace DVModManager.Helpers; + +public static class PlatformHelper +{ + /// + /// Opens a URL in the default browser or a folder in the default file manager, + /// using the appropriate mechanism for the current OS. + /// + public static void Open(string pathOrUrl) + { + try + { + if (OperatingSystem.IsWindows()) + { + Process.Start(new ProcessStartInfo(pathOrUrl) { UseShellExecute = true }); + } + else if (OperatingSystem.IsLinux()) + { + Process.Start("xdg-open", pathOrUrl); + } + } + catch + { + // Ignore – best-effort shell interaction + } + } +} diff --git a/DVModManager/Models/AppSettings.cs b/DVModManager/Models/AppSettings.cs new file mode 100644 index 0000000..584583e --- /dev/null +++ b/DVModManager/Models/AppSettings.cs @@ -0,0 +1,32 @@ +namespace DVModManager.Models; + +public class AppSettings +{ + public string? GamePath { get; set; } + + /// Root storage path for downloads, version archives, logs, profiles. + public string StoragePath { get; set; } = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "DVModManager"); + + public string? NexusApiKey { get; set; } + public string? GitHubToken { get; set; } + + /// "Dark" or "Light" + public string ThemeVariant { get; set; } = "Dark"; + + public bool AutoCheckUpdatesOnStartup { get; set; } = true; + + public bool BackupBeforeChanges { get; set; } = true; + + /// Maximum combined size of all version archives before pruning. Default 5 GB. + public long MaxCacheSizeBytes { get; set; } = 5L * 1024 * 1024 * 1024; + + public string ActiveProfileName { get; set; } = "Default"; + + // --- Derived paths --- + public string ProfilesPath => Path.Combine(StoragePath, "profiles"); + public string VersionsPath => Path.Combine(StoragePath, "versions"); + public string DownloadsPath => Path.Combine(StoragePath, "downloads"); + public string LogsPath => Path.Combine(StoragePath, "logs"); +} diff --git a/DVModManager/Models/ModInfo.cs b/DVModManager/Models/ModInfo.cs new file mode 100644 index 0000000..3cecdeb --- /dev/null +++ b/DVModManager/Models/ModInfo.cs @@ -0,0 +1,36 @@ +using System.Text.Json.Serialization; + +namespace DVModManager.Models; + +/// +/// Mirrors Unity Mod Manager's Info.json schema. +/// Runtime-only fields are tagged [JsonIgnore]. +/// +public class ModInfo +{ + // --- Info.json fields --- + public string Id { get; set; } = ""; + public string DisplayName { get; set; } = ""; + public string Author { get; set; } = ""; + public string Version { get; set; } = "0.0.0"; + public string? ManagerVersion { get; set; } + public string? GameVersion { get; set; } + public string[] Requirements { get; set; } = []; + public string[] LoadAfter { get; set; } = []; + public string? AssemblyName { get; set; } + public string? EntryMethod { get; set; } + public string? HomePage { get; set; } + public string? Repository { get; set; } + public string? Description { get; set; } + + // --- Runtime-only (not persisted) --- + [JsonIgnore] public string FolderPath { get; set; } = ""; + [JsonIgnore] public bool IsActive { get; set; } + [JsonIgnore] public ModState State { get; set; } = ModState.Inactive; + [JsonIgnore] public ModUpdateInfo? PendingUpdate { get; set; } + [JsonIgnore] public bool HasMetadata { get; set; } = true; + + /// Effective display name: falls back to Id if DisplayName is blank. + [JsonIgnore] public string EffectiveDisplayName => + string.IsNullOrWhiteSpace(DisplayName) ? Id : DisplayName; +} diff --git a/DVModManager/Models/ModProfile.cs b/DVModManager/Models/ModProfile.cs new file mode 100644 index 0000000..58a2171 --- /dev/null +++ b/DVModManager/Models/ModProfile.cs @@ -0,0 +1,16 @@ +namespace DVModManager.Models; + +public class ModProfile +{ + public string Name { get; set; } = "Default"; + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime LastModifiedAt { get; set; } = DateTime.UtcNow; + public List Mods { get; set; } = []; +} + +public class ProfileModEntry +{ + public string ModId { get; set; } = ""; + public string Version { get; set; } = ""; + public bool IsActive { get; set; } +} diff --git a/DVModManager/Models/ModState.cs b/DVModManager/Models/ModState.cs new file mode 100644 index 0000000..8d679f8 --- /dev/null +++ b/DVModManager/Models/ModState.cs @@ -0,0 +1,10 @@ +namespace DVModManager.Models; + +public enum ModState +{ + Active, + Inactive, + UpdateAvailable, + MissingDependency, + NoMetadata +} diff --git a/DVModManager/Models/ModUpdateInfo.cs b/DVModManager/Models/ModUpdateInfo.cs new file mode 100644 index 0000000..0fb370e --- /dev/null +++ b/DVModManager/Models/ModUpdateInfo.cs @@ -0,0 +1,16 @@ +namespace DVModManager.Models; + +public class ModUpdateInfo +{ + public string ModId { get; set; } = ""; + public string CurrentVersion { get; set; } = ""; + public string LatestVersion { get; set; } = ""; + + /// "nexus" or "github" + public string Source { get; set; } = ""; + + public string? DownloadUrl { get; set; } + public string? ChangelogUrl { get; set; } + public DateTime? ReleasedAt { get; set; } + public string? ReleaseNotes { get; set; } +} diff --git a/DVModManager/Models/ModVersion.cs b/DVModManager/Models/ModVersion.cs new file mode 100644 index 0000000..c04dee8 --- /dev/null +++ b/DVModManager/Models/ModVersion.cs @@ -0,0 +1,16 @@ +namespace DVModManager.Models; + +public class ModVersion +{ + public string ModId { get; set; } = ""; + public string Version { get; set; } = ""; + + /// Path to the .zip archive in the version cache. + public string ArchivePath { get; set; } = ""; + + /// "nexus", "github", or "manual" + public string Source { get; set; } = "manual"; + + public DateTime ArchivedAt { get; set; } = DateTime.UtcNow; + public long ArchiveSizeBytes { get; set; } +} diff --git a/DVModManager/Program.cs b/DVModManager/Program.cs new file mode 100644 index 0000000..b84f1e4 --- /dev/null +++ b/DVModManager/Program.cs @@ -0,0 +1,8 @@ +using Avalonia; +using DVModManager; + +AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace() + .StartWithClassicDesktopLifetime(args); diff --git a/DVModManager/Services/DialogService.cs b/DVModManager/Services/DialogService.cs new file mode 100644 index 0000000..7727bb0 --- /dev/null +++ b/DVModManager/Services/DialogService.cs @@ -0,0 +1,161 @@ +using Avalonia.Controls; +using Avalonia.Platform.Storage; + +namespace DVModManager.Services; + +public class DialogService : IDialogService +{ + private Window? _owner; + + public void SetOwner(Window owner) => _owner = owner; + + public async Task PickFolderAsync(string title) + { + if (_owner == null) return null; + var provider = TopLevel.GetTopLevel(_owner)?.StorageProvider; + if (provider == null) return null; + + var folders = await provider.OpenFolderPickerAsync(new FolderPickerOpenOptions + { + Title = title, + AllowMultiple = false + }); + + return folders.Count > 0 ? folders[0].Path.LocalPath : null; + } + + public async Task OpenFileAsync(string title, string filterName, string[] extensions) + { + if (_owner == null) return null; + var provider = TopLevel.GetTopLevel(_owner)?.StorageProvider; + if (provider == null) return null; + + var files = await provider.OpenFilePickerAsync(new FilePickerOpenOptions + { + Title = title, + AllowMultiple = false, + FileTypeFilter = + [ + new FilePickerFileType(filterName) + { + Patterns = extensions.Select(e => e.StartsWith("*.") ? e : "*." + e.TrimStart('.')).ToList() + } + ] + }); + + return files.Count > 0 ? files[0].Path.LocalPath : null; + } + + public async Task SaveFileAsync(string title, string filterName, string[] extensions, string defaultName) + { + if (_owner == null) return null; + var provider = TopLevel.GetTopLevel(_owner)?.StorageProvider; + if (provider == null) return null; + + var file = await provider.SaveFilePickerAsync(new FilePickerSaveOptions + { + Title = title, + SuggestedFileName = defaultName, + FileTypeChoices = + [ + new FilePickerFileType(filterName) + { + Patterns = extensions.Select(e => e.StartsWith("*.") ? e : "*." + e.TrimStart('.')).ToList() + } + ] + }); + + return file?.Path.LocalPath; + } + + public async Task ShowMessageAsync(string title, string message) + { + if (_owner == null) return; + + var dialog = new Window + { + Title = title, + Width = 420, + Height = 180, + CanResize = false, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + Content = BuildMessageContent(message, null) + }; + + await dialog.ShowDialog(_owner); + } + + public async Task ConfirmAsync(string title, string message) + { + if (_owner == null) return false; + + bool result = false; + + var dialog = new Window + { + Title = title, + Width = 420, + Height = 180, + CanResize = false, + WindowStartupLocation = WindowStartupLocation.CenterOwner, + Content = BuildMessageContent(message, confirmed => { result = confirmed; }) + }; + + // We'll close the dialog from the button callbacks via a TaskCompletionSource + var tcs = new TaskCompletionSource(); + dialog.Content = BuildConfirmContent(message, answer => + { + result = answer; + dialog.Close(); + tcs.TrySetResult(answer); + }); + + await dialog.ShowDialog(_owner); + return result; + } + + private static Avalonia.Controls.Control BuildMessageContent(string message, Action? closeCallback) + { + var btn = new Button { Content = "OK", HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center }; + btn.Click += (_, _) => closeCallback?.Invoke(true); + + return new StackPanel + { + Margin = new Avalonia.Thickness(20), + Spacing = 16, + Children = + { + new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap }, + btn + } + }; + } + + private static Avalonia.Controls.Control BuildConfirmContent(string message, Action callback) + { + var okBtn = new Button { Content = "Yes", Width = 80 }; + var cancelBtn = new Button { Content = "No", Width = 80 }; + + okBtn.Click += (_, _) => callback(true); + cancelBtn.Click += (_, _) => callback(false); + + var buttons = new StackPanel + { + Orientation = Avalonia.Layout.Orientation.Horizontal, + HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center, + Spacing = 12, + Children = { okBtn, cancelBtn } + }; + + return new StackPanel + { + Margin = new Avalonia.Thickness(20), + Spacing = 16, + Children = + { + new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap }, + buttons + } + }; + } +} diff --git a/DVModManager/Services/FileLoggerProvider.cs b/DVModManager/Services/FileLoggerProvider.cs new file mode 100644 index 0000000..3e1ab0a --- /dev/null +++ b/DVModManager/Services/FileLoggerProvider.cs @@ -0,0 +1,93 @@ +using Microsoft.Extensions.Logging; + +namespace DVModManager.Services; + +/// +/// A simple rolling file logger that writes to %LocalAppData%\DVModManager\logs\dvmm-YYYY-MM-DD.log. +/// One file per calendar day; old entries are appended so a single crash doesn't lose context. +/// Thread-safe via a dedicated background writer queue. +/// +public sealed class FileLoggerProvider : ILoggerProvider +{ + private readonly string _logDirectory; + private readonly LogLevel _minLevel; + private readonly System.Collections.Concurrent.BlockingCollection _queue = new(4096); + private readonly Thread _writerThread; + private bool _disposed; + + public FileLoggerProvider(string logDirectory, LogLevel minLevel = LogLevel.Debug) + { + _logDirectory = logDirectory; + _minLevel = minLevel; + Directory.CreateDirectory(logDirectory); + + _writerThread = new Thread(WriterLoop) { IsBackground = true, Name = "FileLogger" }; + _writerThread.Start(); + } + + public ILogger CreateLogger(string categoryName) => + new FileLogger(categoryName, _minLevel, Enqueue); + + public void Enqueue(string line) => _queue.TryAdd(line); + + private void WriterLoop() + { + foreach (var line in _queue.GetConsumingEnumerable()) + { + try + { + var path = Path.Combine(_logDirectory, + $"dvmm-{DateTime.Now:yyyy-MM-dd}.log"); + File.AppendAllText(path, line + Environment.NewLine); + } + catch { /* never crash the logger */ } + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _queue.CompleteAdding(); + _writerThread.Join(TimeSpan.FromSeconds(2)); + _queue.Dispose(); + } + + private sealed class FileLogger( + string category, + LogLevel minLevel, + Action enqueue) : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(LogLevel level) => level >= minLevel; + + public void Log( + LogLevel level, EventId eventId, TState state, + Exception? exception, Func formatter) + { + if (!IsEnabled(level)) return; + + var prefix = level switch + { + LogLevel.Trace => "TRC", + LogLevel.Debug => "DBG", + LogLevel.Information => "INF", + LogLevel.Warning => "WRN", + LogLevel.Error => "ERR", + LogLevel.Critical => "CRT", + _ => "???" + }; + + var shortCat = category.Contains('.') + ? category[(category.LastIndexOf('.') + 1)..] + : category; + + var message = formatter(state, exception); + var line = $"[{DateTime.Now:HH:mm:ss.fff}] [{prefix}] [{shortCat}] {message}"; + if (exception != null) + line += Environment.NewLine + exception.ToString(); + + enqueue(line); + } + } +} diff --git a/DVModManager/Services/GameDetectionService.cs b/DVModManager/Services/GameDetectionService.cs new file mode 100644 index 0000000..bd7fc20 --- /dev/null +++ b/DVModManager/Services/GameDetectionService.cs @@ -0,0 +1,119 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Text.RegularExpressions; + +namespace DVModManager.Services; + +public sealed class GameDetectionService : IGameDetectionService +{ + private const string ProcessName = "DerailValley"; + + private readonly Timer _pollingTimer; + private bool _lastRunningState; + + public event EventHandler? GameRunningChanged; + + public GameDetectionService() + { + _lastRunningState = IsGameRunning(); + _pollingTimer = new Timer(Poll, null, TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(3)); + } + + private void Poll(object? state) + { + var running = IsGameRunning(); + if (running != _lastRunningState) + { + _lastRunningState = running; + GameRunningChanged?.Invoke(this, running); + } + } + + public bool IsGameRunning() + { + try { return Process.GetProcessesByName(ProcessName).Length > 0; } + catch { return false; } + } + + public string? DetectGamePath() + { + if (OperatingSystem.IsWindows()) + { + var path = DetectViaRegistryWindows(); + if (path != null) return path; + } + + return DetectViaSteamConfigFile(); + } + + [System.Runtime.Versioning.SupportedOSPlatform("windows")] + private string? DetectViaRegistryWindows() + { + try + { + using var key = + Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Wow6432Node\Valve\Steam") + ?? Microsoft.Win32.Registry.LocalMachine.OpenSubKey(@"SOFTWARE\Valve\Steam"); + + if (key?.GetValue("InstallPath") is not string steamPath) return null; + return FindDerailValleyInLibraries(steamPath); + } + catch { return null; } + } + + private string? DetectViaSteamConfigFile() + { + var steamPaths = new List(); + + if (OperatingSystem.IsLinux()) + { + var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile); + steamPaths.Add(Path.Combine(home, ".steam", "steam")); + steamPaths.Add(Path.Combine(home, ".local", "share", "Steam")); + } + + foreach (var steamPath in steamPaths) + { + if (!Directory.Exists(steamPath)) continue; + var result = FindDerailValleyInLibraries(steamPath); + if (result != null) return result; + } + return null; + } + + private string? FindDerailValleyInLibraries(string steamPath) + { + var vdfPath = Path.Combine(steamPath, "steamapps", "libraryfolders.vdf"); + var libraryPaths = File.Exists(vdfPath) ? ParseLibraryFolders(vdfPath) : []; + libraryPaths.Insert(0, steamPath); + + foreach (var lib in libraryPaths) + { + var gamePath = Path.Combine(lib, "steamapps", "common", "Derail Valley"); + if (ValidateGamePath(gamePath)) return gamePath; + } + return null; + } + + private static List ParseLibraryFolders(string vdfPath) + { + var paths = new List(); + try + { + var content = File.ReadAllText(vdfPath); + // Match "path" entries in the VDF (supports both old and new format) + foreach (Match m in Regex.Matches(content, @"""path""\s+""([^""]+)""")) + { + var path = m.Groups[1].Value.Replace(@"\\", @"\"); + if (Directory.Exists(path)) paths.Add(path); + } + } + catch { /* ignore VDF parse errors */ } + return paths; + } + + public bool ValidateGamePath(string path) => + Directory.Exists(path) && Directory.Exists(Path.Combine(path, "DerailValley_Data")); + + public void Dispose() => _pollingTimer.Dispose(); +} diff --git a/DVModManager/Services/GitHubModsService.cs b/DVModManager/Services/GitHubModsService.cs new file mode 100644 index 0000000..d19fc5e --- /dev/null +++ b/DVModManager/Services/GitHubModsService.cs @@ -0,0 +1,219 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using DVModManager.Models; +using Microsoft.Extensions.Logging; +using Octokit; + +namespace DVModManager.Services; + +public class GitHubModsService : IGitHubModsService +{ + private readonly ISettingsService _settings; + private readonly ILogger _logger; + private GitHubClient? _client; + + public GitHubModsService(ISettingsService settings, ILogger logger) + { + _settings = settings; + _logger = logger; + } + + private GitHubClient GetClient() + { + if (_client != null) return _client; + + _client = new GitHubClient(new ProductHeaderValue("DVModManager")); + + var token = _settings.Settings.GitHubToken; + if (!string.IsNullOrWhiteSpace(token)) + _client.Credentials = new Credentials(token); + + return _client; + } + + public async Task CheckUpdateAsync(ModInfo mod, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(mod.Repository)) return null; + + // UMM convention: Repository may point directly to a releases JSON file + if (mod.Repository.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) + return await CheckUpdateViaJsonAsync(mod, ct); + + var (owner, repo) = ParseGitHubUrl(mod.Repository); + if (owner == null || repo == null) return null; + + try + { + var release = await GetClient().Repository.Release.GetLatest(owner, repo); + var latestVersion = NormalizeVersion(release.TagName); + + // If we can't parse a valid semver from the tag, skip — don't use string compare + if (!Version.TryParse(latestVersion, out _)) + { + _logger.LogDebug( + "Skipping update for {Id}: tag '{Tag}' couldn't be parsed as a version", + mod.Id, release.TagName); + return null; + } + + if (!IsNewerVersion(mod.Version, latestVersion)) return null; + + // Find the best downloadable asset (prefer .zip) + var asset = release.Assets + .OrderByDescending(a => a.Name.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + .FirstOrDefault(); + + return new ModUpdateInfo + { + ModId = mod.Id, + CurrentVersion = mod.Version, + LatestVersion = latestVersion, + Source = "github", + DownloadUrl = asset?.BrowserDownloadUrl, + ChangelogUrl = release.HtmlUrl, + ReleasedAt = release.PublishedAt?.UtcDateTime, + ReleaseNotes = release.Body + }; + } + catch (NotFoundException) + { + _logger.LogDebug("No GitHub releases found for {Owner}/{Repo}", owner, repo); + return null; + } + catch (RateLimitExceededException ex) + { + _logger.LogWarning("GitHub rate limit exceeded. Resets at {Reset}", ex.Reset); + return null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking GitHub update for {Id}", mod.Id); + return null; + } + } + + /// + /// UMM mods sometimes set Repository to a raw URL pointing to a releases.json / repository.json. + /// Format expected: { "Releases": [ { "Version": "1.2.3", "DownloadUrl": "..." } ] } + /// + private async Task CheckUpdateViaJsonAsync(ModInfo mod, CancellationToken ct) + { + try + { + using var http = new HttpClient(); + http.DefaultRequestHeaders.UserAgent.ParseAdd("DVModManager/1.0"); + var json = await http.GetStringAsync(mod.Repository, ct); + using var doc = System.Text.Json.JsonDocument.Parse(json); + + // Support two common shapes: top-level "Version" or "Releases[0].Version" + string? latestVersion = null; + string? downloadUrl = null; + + if (doc.RootElement.TryGetProperty("Version", out var vProp)) + latestVersion = vProp.GetString(); + + if (doc.RootElement.TryGetProperty("Releases", out var releases) && + releases.ValueKind == System.Text.Json.JsonValueKind.Array) + { + foreach (var r in releases.EnumerateArray()) + { + if (r.TryGetProperty("Version", out var rv)) + latestVersion ??= rv.GetString(); + if (r.TryGetProperty("DownloadUrl", out var du)) + downloadUrl ??= du.GetString(); + break; // first entry is the latest + } + } + + if (latestVersion == null) return null; + latestVersion = NormalizeVersion(latestVersion); + if (!IsNewerVersion(mod.Version, latestVersion)) return null; + + return new ModUpdateInfo + { + ModId = mod.Id, + CurrentVersion = mod.Version, + LatestVersion = latestVersion, + Source = "github", + DownloadUrl = downloadUrl, + ChangelogUrl = mod.Repository + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching releases JSON for {Id} from {Url}", mod.Id, mod.Repository); + return null; + } + } + + public async Task DownloadReleaseAssetAsync( + string downloadUrl, string destinationPath, + IProgress? progress = null, CancellationToken ct = default) + { + // GitHub release asset downloads are plain HTTPS — use HttpClient + using var http = new HttpClient(); + http.DefaultRequestHeaders.UserAgent.ParseAdd("DVModManager/1.0"); + + using var response = await http.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, ct); + response.EnsureSuccessStatusCode(); + + var totalBytes = response.Content.Headers.ContentLength; + await using var stream = await response.Content.ReadAsStreamAsync(ct); + await using var file = File.Create(destinationPath); + + var buffer = new byte[81920]; + long downloaded = 0; + int read; + + while ((read = await stream.ReadAsync(buffer, ct)) > 0) + { + await file.WriteAsync(buffer.AsMemory(0, read), ct); + downloaded += read; + if (totalBytes.HasValue) + progress?.Report((double)downloaded / totalBytes.Value); + } + + return destinationPath; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private static (string? Owner, string? Repo) ParseGitHubUrl(string url) + { + var match = Regex.Match(url, @"github\.com/([^/]+)/([^/\s?#]+)", RegexOptions.IgnoreCase); + if (!match.Success) return (null, null); + return (match.Groups[1].Value, match.Groups[2].Value.TrimEnd('/')); + } + + /// + /// Extracts the numeric semver from a release tag. + /// Handles: "v1.2.3", "V1.2.3", "ModName-v1.2.3", "ModName_v1.2.3", "1.2.3" + /// + private static string NormalizeVersion(string tag) + { + var s = tag.Trim(); + + // Simple leading-v case + if (Regex.IsMatch(s, @"^[vV]\d")) return s[1..].Trim(); + + // Complex tag: extract trailing semver-like number (e.g. "ModName-v1.2.3" → "1.2.3") + var match = Regex.Match(s, @"[vV]?(\d+\.\d+(?:\.\d+){0,2})$"); + if (match.Success) return match.Groups[1].Value; + + return s; + } + + /// + /// Returns true only when both versions parse as System.Version and latest > current. + /// Deliberately returns false when versions are unparseable to avoid false positives. + /// + private static bool IsNewerVersion(string current, string latest) + { + if (Version.TryParse(NormalizeVersion(current), out var c) && + Version.TryParse(NormalizeVersion(latest), out var l)) + return l > c; + + // Cannot compare reliably — do not report as an update + return false; + } +} diff --git a/DVModManager/Services/IDialogService.cs b/DVModManager/Services/IDialogService.cs new file mode 100644 index 0000000..5fb24e5 --- /dev/null +++ b/DVModManager/Services/IDialogService.cs @@ -0,0 +1,13 @@ +using Avalonia.Controls; + +namespace DVModManager.Services; + +public interface IDialogService +{ + void SetOwner(Window owner); + Task PickFolderAsync(string title); + Task OpenFileAsync(string title, string filterName, string[] extensions); + Task SaveFileAsync(string title, string filterName, string[] extensions, string defaultName); + Task ShowMessageAsync(string title, string message); + Task ConfirmAsync(string title, string message); +} diff --git a/DVModManager/Services/IGameDetectionService.cs b/DVModManager/Services/IGameDetectionService.cs new file mode 100644 index 0000000..cbd93e6 --- /dev/null +++ b/DVModManager/Services/IGameDetectionService.cs @@ -0,0 +1,11 @@ +using DVModManager.Models; + +namespace DVModManager.Services; + +public interface IGameDetectionService : IDisposable +{ + string? DetectGamePath(); + bool ValidateGamePath(string path); + bool IsGameRunning(); + event EventHandler GameRunningChanged; +} diff --git a/DVModManager/Services/IGitHubModsService.cs b/DVModManager/Services/IGitHubModsService.cs new file mode 100644 index 0000000..3bf4232 --- /dev/null +++ b/DVModManager/Services/IGitHubModsService.cs @@ -0,0 +1,9 @@ +using DVModManager.Models; + +namespace DVModManager.Services; + +public interface IGitHubModsService +{ + Task CheckUpdateAsync(ModInfo mod, CancellationToken ct = default); + Task DownloadReleaseAssetAsync(string downloadUrl, string destinationPath, IProgress? progress = null, CancellationToken ct = default); +} diff --git a/DVModManager/Services/IModDiscoveryService.cs b/DVModManager/Services/IModDiscoveryService.cs new file mode 100644 index 0000000..4702f09 --- /dev/null +++ b/DVModManager/Services/IModDiscoveryService.cs @@ -0,0 +1,12 @@ +using DVModManager.Models; + +namespace DVModManager.Services; + +public interface IModDiscoveryService : IDisposable +{ + Task> ScanAllModsAsync(string gamePath); + Task ParseModInfoAsync(string folderPath, bool isActive); + void StartWatching(string gamePath); + void StopWatching(); + event EventHandler? ModsChanged; +} diff --git a/DVModManager/Services/IModInstallService.cs b/DVModManager/Services/IModInstallService.cs new file mode 100644 index 0000000..9608b0e --- /dev/null +++ b/DVModManager/Services/IModInstallService.cs @@ -0,0 +1,14 @@ +using DVModManager.Models; + +namespace DVModManager.Services; + +public interface IModInstallService +{ + Task ActivateModAsync(ModInfo mod, string gamePath, CancellationToken ct = default); + Task DeactivateModAsync(ModInfo mod, string gamePath, CancellationToken ct = default); + Task InstallFromArchiveAsync(string archivePath, string gamePath, string storagePath, bool activate = true, CancellationToken ct = default); + Task UninstallModAsync(ModInfo mod, string gamePath, string storagePath, bool hardDelete = false, CancellationToken ct = default); + Task RollbackToVersionAsync(string modId, string version, string gamePath, string storagePath, CancellationToken ct = default); + Task UpdateModAsync(ModInfo mod, ModUpdateInfo update, string gamePath, string storagePath, IProgress? progress = null, CancellationToken ct = default); + Task BackupModsFolderAsync(string gamePath, string storagePath); +} diff --git a/DVModManager/Services/INexusModsService.cs b/DVModManager/Services/INexusModsService.cs new file mode 100644 index 0000000..96b013f --- /dev/null +++ b/DVModManager/Services/INexusModsService.cs @@ -0,0 +1,10 @@ +using DVModManager.Models; + +namespace DVModManager.Services; + +public interface INexusModsService +{ + bool IsConfigured { get; } + Task CheckUpdateAsync(ModInfo mod, CancellationToken ct = default); + Task DownloadModFileAsync(string downloadUrl, string destinationPath, IProgress? progress = null, CancellationToken ct = default); +} diff --git a/DVModManager/Services/IProfileService.cs b/DVModManager/Services/IProfileService.cs new file mode 100644 index 0000000..9d40678 --- /dev/null +++ b/DVModManager/Services/IProfileService.cs @@ -0,0 +1,23 @@ +using DVModManager.Models; + +namespace DVModManager.Services; + +public interface IProfileService +{ + Task> GetProfilesAsync(string profilesPath); + Task GetProfileAsync(string name, string profilesPath); + Task SaveProfileAsync(ModProfile profile, string profilesPath); + Task DeleteProfileAsync(string name, string profilesPath); + Task ExportProfileAsync(ModProfile profile, string destinationFilePath); + Task ImportProfileAsync(string sourceFilePath); + + /// + /// Returns a list describing what operations would occur if the profile were applied. + /// + ProfileDiff ComputeDiff(ModProfile profile, IReadOnlyList currentMods); +} + +public record ProfileDiff( + IReadOnlyList ToActivate, + IReadOnlyList ToDeactivate, + IReadOnlyList<(string ModId, string FromVersion, string ToVersion)> ToRollback); diff --git a/DVModManager/Services/ISettingsService.cs b/DVModManager/Services/ISettingsService.cs new file mode 100644 index 0000000..6b810ab --- /dev/null +++ b/DVModManager/Services/ISettingsService.cs @@ -0,0 +1,10 @@ +using DVModManager.Models; + +namespace DVModManager.Services; + +public interface ISettingsService +{ + AppSettings Settings { get; } + Task SaveAsync(); + Task LoadAsync(); +} diff --git a/DVModManager/Services/IUpdateService.cs b/DVModManager/Services/IUpdateService.cs new file mode 100644 index 0000000..d9ecafc --- /dev/null +++ b/DVModManager/Services/IUpdateService.cs @@ -0,0 +1,9 @@ +using DVModManager.Models; + +namespace DVModManager.Services; + +public interface IUpdateService +{ + Task> CheckAllUpdatesAsync(IReadOnlyList mods, CancellationToken ct = default); + Task CheckUpdateAsync(ModInfo mod, CancellationToken ct = default); +} diff --git a/DVModManager/Services/IVersionCacheService.cs b/DVModManager/Services/IVersionCacheService.cs new file mode 100644 index 0000000..69091a2 --- /dev/null +++ b/DVModManager/Services/IVersionCacheService.cs @@ -0,0 +1,19 @@ +using DVModManager.Models; + +namespace DVModManager.Services; + +public interface IVersionCacheService +{ + /// Zip the mod's current folder into the version cache before updating/rolling back. + Task ArchiveCurrentVersionAsync(ModInfo mod, string storagePath); + + /// Returns all archived versions for a given mod, newest first. + Task> GetVersionHistoryAsync(string modId, string storagePath); + + /// Retrieves the path to a specific archived version zip, or null if not cached. + Task GetVersionArchivePathAsync(string modId, string version, string storagePath); + + Task DeleteVersionAsync(string modId, string version, string storagePath); + Task GetCacheSizeAsync(string storagePath); + Task PruneCacheAsync(string storagePath, long maxSizeBytes); +} diff --git a/DVModManager/Services/ModDiscoveryService.cs b/DVModManager/Services/ModDiscoveryService.cs new file mode 100644 index 0000000..b546552 --- /dev/null +++ b/DVModManager/Services/ModDiscoveryService.cs @@ -0,0 +1,131 @@ +using System.Text.Json; +using DVModManager.Models; + +namespace DVModManager.Services; + +public sealed class ModDiscoveryService : IModDiscoveryService +{ + private FileSystemWatcher? _activeWatcher; + private FileSystemWatcher? _inactiveWatcher; + + public event EventHandler? ModsChanged; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + AllowTrailingCommas = true, + ReadCommentHandling = JsonCommentHandling.Skip + }; + + public async Task> ScanAllModsAsync(string gamePath) + { + var mods = new List(); + + var activeDir = Path.Combine(gamePath, "Mods"); + var inactiveDir = Path.Combine(gamePath, "Mods.inactive"); + + if (Directory.Exists(activeDir)) + { + foreach (var dir in Directory.GetDirectories(activeDir)) + { + var mod = await ParseModInfoAsync(dir, isActive: true); + if (mod != null) mods.Add(mod); + } + } + + if (Directory.Exists(inactiveDir)) + { + foreach (var dir in Directory.GetDirectories(inactiveDir)) + { + var mod = await ParseModInfoAsync(dir, isActive: false); + if (mod != null) mods.Add(mod); + } + } + + return mods; + } + + public async Task ParseModInfoAsync(string folderPath, bool isActive) + { + var infoPath = Path.Combine(folderPath, "Info.json"); + + ModInfo mod; + + if (!File.Exists(infoPath)) + { + // Orphan folder — no Info.json + mod = new ModInfo + { + Id = Path.GetFileName(folderPath), + DisplayName = Path.GetFileName(folderPath), + HasMetadata = false, + State = ModState.NoMetadata + }; + } + else + { + try + { + var json = await File.ReadAllTextAsync(infoPath); + mod = JsonSerializer.Deserialize(json, JsonOptions) ?? new ModInfo(); + } + catch + { + mod = new ModInfo + { + Id = Path.GetFileName(folderPath), + HasMetadata = false, + State = ModState.NoMetadata + }; + } + } + + mod.FolderPath = folderPath; + mod.IsActive = isActive; + if (mod.State == ModState.Inactive || mod.State == ModState.Active) + mod.State = isActive ? ModState.Active : ModState.Inactive; + + return mod; + } + + public void StartWatching(string gamePath) + { + StopWatching(); + TrySetupWatcher(Path.Combine(gamePath, "Mods"), ref _activeWatcher); + TrySetupWatcher(Path.Combine(gamePath, "Mods.inactive"), ref _inactiveWatcher); + } + + private void TrySetupWatcher(string path, ref FileSystemWatcher? watcher) + { + try + { + Directory.CreateDirectory(path); + watcher = new FileSystemWatcher(path) + { + NotifyFilter = NotifyFilters.DirectoryName | NotifyFilters.FileName, + IncludeSubdirectories = false, + EnableRaisingEvents = true + }; + watcher.Created += OnFileSystemChange; + watcher.Deleted += OnFileSystemChange; + watcher.Renamed += OnFileSystemChange; + } + catch { /* watch is optional */ } + } + + private void OnFileSystemChange(object sender, FileSystemEventArgs e) + { + // Debounce slightly so rapid changes (e.g., unzipping) fire only once + Task.Delay(500).ContinueWith(_ => ModsChanged?.Invoke(this, EventArgs.Empty)); + } + + public void StopWatching() + { + _activeWatcher?.Dispose(); + _inactiveWatcher?.Dispose(); + _activeWatcher = null; + _inactiveWatcher = null; + } + + public void Dispose() => StopWatching(); +} diff --git a/DVModManager/Services/ModInstallService.cs b/DVModManager/Services/ModInstallService.cs new file mode 100644 index 0000000..96c9b73 --- /dev/null +++ b/DVModManager/Services/ModInstallService.cs @@ -0,0 +1,451 @@ +using System.IO.Compression; +using System.Text.Json; +using DVModManager.Models; +using Microsoft.Extensions.Logging; + +namespace DVModManager.Services; + +public class ModInstallService : IModInstallService +{ + private readonly IVersionCacheService _versionCache; + private readonly ILogger _logger; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + AllowTrailingCommas = true + }; + + public ModInstallService(IVersionCacheService versionCache, ILogger logger) + { + _versionCache = versionCache; + _logger = logger; + } + + // ── Activate: Mods.inactive/{folder} → Mods/{folder} ─────────────────────────────────────── + + public async Task ActivateModAsync(ModInfo mod, string gamePath, CancellationToken ct = default) + { + // Use the stored FolderPath so we handle mods whose folder name ≠ mod.Id + var inactivePath = mod.FolderPath; + var folderName = Path.GetFileName(inactivePath); + var activePath = Path.Combine(gamePath, "Mods", folderName); + + // Fallback: if FolderPath is absent or wrong, try both conventions + if (!Directory.Exists(inactivePath)) + { + var byId = Path.Combine(gamePath, "Mods.inactive", mod.Id); + var byFolder = Path.Combine(gamePath, "Mods.inactive", folderName); + inactivePath = Directory.Exists(byId) ? byId + : Directory.Exists(byFolder) ? byFolder + : null!; + if (inactivePath == null) + { + _logger.LogWarning("Cannot activate {Id}: folder not found in Mods.inactive", mod.Id); + return false; + } + folderName = Path.GetFileName(inactivePath); + activePath = Path.Combine(gamePath, "Mods", folderName); + } + + try + { + Directory.CreateDirectory(Path.Combine(gamePath, "Mods")); + await Task.Run(() => MoveDirectory(inactivePath, activePath), ct); + mod.FolderPath = activePath; + mod.IsActive = true; + mod.State = ModState.Active; + _logger.LogInformation("Activated mod {Id} (folder: {Folder})", mod.Id, folderName); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to activate mod {Id}", mod.Id); + return false; + } + } + + // ── Deactivate: Mods/{folder} → Mods.inactive/{folder} ───────────────────────────────────── + + public async Task DeactivateModAsync(ModInfo mod, string gamePath, CancellationToken ct = default) + { + // Use the stored FolderPath so we handle mods whose folder name ≠ mod.Id + var activePath = mod.FolderPath; + var folderName = Path.GetFileName(activePath); + var inactivePath = Path.Combine(gamePath, "Mods.inactive", folderName); + + // Fallback: if FolderPath is absent or wrong, try both conventions + if (!Directory.Exists(activePath)) + { + var byId = Path.Combine(gamePath, "Mods", mod.Id); + var byFolder = Path.Combine(gamePath, "Mods", folderName); + activePath = Directory.Exists(byId) ? byId + : Directory.Exists(byFolder) ? byFolder + : null!; + if (activePath == null) + { + _logger.LogWarning("Cannot deactivate {Id}: folder not found in Mods", mod.Id); + return false; + } + folderName = Path.GetFileName(activePath); + inactivePath = Path.Combine(gamePath, "Mods.inactive", folderName); + } + + try + { + await Task.Run(() => + { + Directory.CreateDirectory(Path.Combine(gamePath, "Mods.inactive")); + if (Directory.Exists(inactivePath)) Directory.Delete(inactivePath, true); + MoveDirectory(activePath, inactivePath); + }, ct); + + mod.FolderPath = inactivePath; + mod.IsActive = false; + mod.State = ModState.Inactive; + _logger.LogInformation("Deactivated mod {Id} (folder: {Folder})", mod.Id, folderName); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to deactivate mod {Id}", mod.Id); + return false; + } + } + + // ── Install: extract archive → Mods/ or Mods.inactive/ ─────────────────── + + public async Task InstallFromArchiveAsync( + string archivePath, string gamePath, string storagePath, + bool activate = true, CancellationToken ct = default) + { + try + { + // Extract to a temp dir first to discover the mod id + var tempDir = Path.Combine(Path.GetTempPath(), "dvmm_" + Guid.NewGuid()); + await Task.Run(() => ZipFile.ExtractToDirectory(archivePath, tempDir, overwriteFiles: true), ct); + + // Determine the mod root: either the extracted folder itself or a single subdirectory + var modRoot = FindModRoot(tempDir); + if (modRoot == null) + { + Directory.Delete(tempDir, true); + _logger.LogError("No Info.json found in archive {Archive}", archivePath); + return null; + } + + var infoPath = Path.Combine(modRoot, "Info.json"); + var json = await File.ReadAllTextAsync(infoPath, ct); + var modInfo = JsonSerializer.Deserialize(json, JsonOptions); + if (modInfo == null) { Directory.Delete(tempDir, true); return null; } + + var targetDir = activate + ? Path.Combine(gamePath, "Mods", modInfo.Id) + : Path.Combine(gamePath, "Mods.inactive", modInfo.Id); + + Directory.CreateDirectory(Path.GetDirectoryName(targetDir)!); + + // Archive the existing installation before overwriting + if (Directory.Exists(targetDir)) + { + var existing = new ModInfo + { + Id = modInfo.Id, + Version = modInfo.Version, // use new version as label; existing may differ + FolderPath = targetDir + }; + // Re-read the existing Info.json if available to get accurate version + var existingInfo = Path.Combine(targetDir, "Info.json"); + if (File.Exists(existingInfo)) + { + try + { + var existingMod = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(existingInfo, ct), JsonOptions); + if (existingMod != null) + { + existingMod.FolderPath = targetDir; + await _versionCache.ArchiveCurrentVersionAsync(existingMod, storagePath); + } + } + catch { /* archive failure is non-fatal */ } + } + Directory.Delete(targetDir, true); + } + + await Task.Run(() => + { + MoveDirectory(modRoot, targetDir); + if (Directory.Exists(tempDir)) Directory.Delete(tempDir, true); + }, ct); + + modInfo.FolderPath = targetDir; + modInfo.IsActive = activate; + modInfo.State = activate ? ModState.Active : ModState.Inactive; + + // Copy archive to downloads cache + var downloadsDir = Path.Combine(storagePath, "downloads", modInfo.Id); + Directory.CreateDirectory(downloadsDir); + var destArchive = Path.Combine(downloadsDir, Path.GetFileName(archivePath)); + if (!File.Exists(destArchive)) File.Copy(archivePath, destArchive); + + _logger.LogInformation("Installed mod {Id} v{Version}", modInfo.Id, modInfo.Version); + return modInfo; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to install from archive {Archive}", archivePath); + return null; + } + } + + // ── Uninstall ───────────────────────────────────────────────────────────── + + public async Task UninstallModAsync(ModInfo mod, string gamePath, string storagePath, bool hardDelete = false, CancellationToken ct = default) + { + try + { + if (hardDelete) + { + if (Directory.Exists(mod.FolderPath)) Directory.Delete(mod.FolderPath, true); + _logger.LogInformation("Hard-deleted mod {Id}", mod.Id); + } + else + { + // Archive current state into the version cache before removing + if (Directory.Exists(mod.FolderPath)) + await _versionCache.ArchiveCurrentVersionAsync(mod, storagePath); + + if (Directory.Exists(mod.FolderPath)) Directory.Delete(mod.FolderPath, true); + _logger.LogInformation("Soft-deleted mod {Id}", mod.Id); + } + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to uninstall mod {Id}", mod.Id); + return false; + } + } + + // ── Rollback ────────────────────────────────────────────────────────────── + + public async Task RollbackToVersionAsync( + string modId, string version, string gamePath, string storagePath, CancellationToken ct = default) + { + try + { + var archivePath = await _versionCache.GetVersionArchivePathAsync(modId, version, storagePath); + if (archivePath == null) + { + _logger.LogError("No archive found for {Id} v{Version}", modId, version); + return false; + } + + // Determine current folder (active or inactive) + var activePath = Path.Combine(gamePath, "Mods", modId); + var inactivePath = Path.Combine(gamePath, "Mods.inactive", modId); + var currentPath = Directory.Exists(activePath) ? activePath : inactivePath; + var isActive = currentPath == activePath; + + // Archive current before overwriting + if (Directory.Exists(currentPath)) + { + // Get current version from Info.json + var infoPath = Path.Combine(currentPath, "Info.json"); + if (File.Exists(infoPath)) + { + var currentMod = JsonSerializer.Deserialize( + await File.ReadAllTextAsync(infoPath, ct), JsonOptions); + if (currentMod != null) + { + currentMod.FolderPath = currentPath; + await _versionCache.ArchiveCurrentVersionAsync(currentMod, storagePath); + } + } + Directory.Delete(currentPath, true); + } + + await Task.Run(() => ZipFile.ExtractToDirectory(archivePath, currentPath, overwriteFiles: true), ct); + _logger.LogInformation("Rolled back {Id} to v{Version}", modId, version); + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Rollback failed for {Id}", modId); + return false; + } + } + + // ── Update ──────────────────────────────────────────────────────────────── + + public async Task UpdateModAsync( + ModInfo mod, ModUpdateInfo update, string gamePath, string storagePath, + IProgress? progress = null, CancellationToken ct = default) + { + if (string.IsNullOrEmpty(update.DownloadUrl)) + { + _logger.LogError("No download URL for update of {Id}", mod.Id); + return false; + } + + try + { + // 1. Download the new zip first — before touching anything on disk + var downloadDir = Path.Combine(storagePath, "downloads", mod.Id); + Directory.CreateDirectory(downloadDir); + var downloadPath = Path.Combine(downloadDir, $"{update.LatestVersion}.zip"); + + using var http = new HttpClient(); + http.DefaultRequestHeaders.UserAgent.ParseAdd("DVModManager/1.0"); + using var response = await http.GetAsync( + update.DownloadUrl, HttpCompletionOption.ResponseHeadersRead, ct); + response.EnsureSuccessStatusCode(); + + var totalBytes = response.Content.Headers.ContentLength; + await using var netStream = await response.Content.ReadAsStreamAsync(ct); + { + await using var fileStream = File.Create(downloadPath); + var buffer = new byte[81920]; + long downloaded = 0; + int read; + while ((read = await netStream.ReadAsync(buffer, ct)) > 0) + { + await fileStream.WriteAsync(buffer.AsMemory(0, read), ct); + downloaded += read; + if (totalBytes > 0) + progress?.Report((double)downloaded / totalBytes.Value); + } + } // fileStream closed & flushed here + + // 2. Validate that the zip actually contains the same mod before touching anything + var zipModId = await ReadModIdFromZipAsync(downloadPath, ct); + if (zipModId == null) + { + _logger.LogError( + "Update aborted for {Id}: could not find Info.json in the downloaded zip", mod.Id); + File.Delete(downloadPath); + return false; + } + if (!string.Equals(zipModId, mod.Id, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogError( + "Update aborted for {Id}: zip contains mod '{ZipId}' — mod ID mismatch", mod.Id, zipModId); + File.Delete(downloadPath); + return false; + } + + // 3. Archive current version and remove original folder + await _versionCache.ArchiveCurrentVersionAsync(mod, storagePath); + + // Remove the original folder now that it is archived. + // InstallFromArchiveAsync uses modInfo.Id (from the zip) to pick the target path, + // which may differ from mod.FolderPath (actual folder name on disk). + if (Directory.Exists(mod.FolderPath)) + await Task.Run(() => Directory.Delete(mod.FolderPath, true), ct); + + // 4. Install — preserves active/inactive state + var result = await InstallFromArchiveAsync( + downloadPath, gamePath, storagePath, mod.IsActive, ct); + return result != null; + } + catch (Exception ex) + { + _logger.LogError(ex, "Update failed for {Id}", mod.Id); + return false; + } + } + + // ── Backup whole Mods folder ────────────────────────────────────────────── + + public async Task BackupModsFolderAsync(string gamePath, string storagePath) + { + var modsDir = Path.Combine(gamePath, "Mods"); + var backupsDir = Path.Combine(storagePath, "backups"); + Directory.CreateDirectory(backupsDir); + + var timestamp = DateTime.Now.ToString("yyyy-MM-dd_HH-mm-ss"); + var backupPath = Path.Combine(backupsDir, $"Mods_backup_{timestamp}.zip"); + + if (Directory.Exists(modsDir)) + await Task.Run(() => ZipFile.CreateFromDirectory(modsDir, backupPath, CompressionLevel.Fastest, false)); + + _logger.LogInformation("Created Mods backup at {Path}", backupPath); + return backupPath; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + /// + /// Moves a directory, handling cross-volume scenarios where Directory.Move fails. + /// Falls back to recursive copy + delete when source and destination are on different roots. + /// + private static void MoveDirectory(string source, string destination) + { + try + { + Directory.Move(source, destination); + } + catch (IOException) + { + // Cross-volume: copy then delete + CopyDirectoryRecursive(source, destination); + Directory.Delete(source, true); + } + } + + private static void CopyDirectoryRecursive(string source, string destination) + { + Directory.CreateDirectory(destination); + foreach (var file in Directory.GetFiles(source)) + File.Copy(file, Path.Combine(destination, Path.GetFileName(file)), overwrite: true); + foreach (var dir in Directory.GetDirectories(source)) + CopyDirectoryRecursive(dir, Path.Combine(destination, Path.GetFileName(dir))); + } + + private static string? FindModRoot(string extractedDir) + { + // Check if Info.json is directly in the extracted root + if (File.Exists(Path.Combine(extractedDir, "Info.json"))) return extractedDir; + + // Check one level deep (common pattern: archive contains a single mod folder) + foreach (var subDir in Directory.GetDirectories(extractedDir)) + { + if (File.Exists(Path.Combine(subDir, "Info.json"))) return subDir; + } + return null; + } + + /// + /// Opens a zip without fully extracting it and returns the mod Id from Info.json, + /// or null if Info.json is missing or the Id field is absent. + /// + private async Task ReadModIdFromZipAsync(string zipPath, CancellationToken ct) + { + try + { + using var archive = ZipFile.OpenRead(zipPath); + + // Find Info.json at the root or one directory deep + var entry = archive.Entries.FirstOrDefault(e => + string.Equals(e.Name, "Info.json", StringComparison.OrdinalIgnoreCase) + && e.FullName.Count(c => c == '/') <= 1); + + if (entry == null) return null; + + await using var stream = entry.Open(); + using var reader = new StreamReader(stream); + var json = await reader.ReadToEndAsync(ct); + var doc = JsonSerializer.Deserialize(json, JsonOptions); + if (doc.TryGetProperty("Id", out var idElem) || doc.TryGetProperty("id", out idElem)) + return idElem.GetString(); + + return null; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not read Info.json from zip {Path}", zipPath); + return null; + } + } +} diff --git a/DVModManager/Services/NexusModsService.cs b/DVModManager/Services/NexusModsService.cs new file mode 100644 index 0000000..a0e698e --- /dev/null +++ b/DVModManager/Services/NexusModsService.cs @@ -0,0 +1,134 @@ +using System.Net.Http.Json; +using System.Text.RegularExpressions; +using DVModManager.Models; +using Microsoft.Extensions.Logging; + +namespace DVModManager.Services; + +public class NexusModsService : INexusModsService +{ + private const string GameDomain = "derailvalley"; + private const string BaseUrl = "https://api.nexusmods.com/v1"; + + private readonly ISettingsService _settings; + private readonly HttpClient _http; + private readonly ILogger _logger; + + public NexusModsService(ISettingsService settings, IHttpClientFactory httpClientFactory, ILogger logger) + { + _settings = settings; + _http = httpClientFactory.CreateClient("nexus"); + _logger = logger; + } + + public bool IsConfigured => !string.IsNullOrWhiteSpace(_settings.Settings.NexusApiKey); + + public async Task CheckUpdateAsync(ModInfo mod, CancellationToken ct = default) + { + if (!IsConfigured) return null; + + var nexusId = ExtractNexusModId(mod.HomePage); + if (nexusId == null) return null; + + try + { + ConfigureHeaders(); + var response = await _http.GetAsync( + $"{BaseUrl}/games/{GameDomain}/mods/{nexusId}.json", ct); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("Nexus API returned {Code} for mod {Id}", response.StatusCode, mod.Id); + return null; + } + + var nexusMod = await response.Content.ReadFromJsonAsync(cancellationToken: ct); + if (nexusMod == null) return null; + + var latestVersion = nexusMod.Version?.Trim() ?? ""; + if (!IsNewerVersion(mod.Version, latestVersion)) return null; + + return new ModUpdateInfo + { + ModId = mod.Id, + CurrentVersion = mod.Version, + LatestVersion = latestVersion, + Source = "nexus", + ChangelogUrl = $"https://www.nexusmods.com/{GameDomain}/mods/{nexusId}", + ReleasedAt = nexusMod.UpdatedTime.HasValue + ? DateTimeOffset.FromUnixTimeSeconds(nexusMod.UpdatedTime.Value).UtcDateTime + : null + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error checking Nexus update for {Id}", mod.Id); + return null; + } + } + + public async Task DownloadModFileAsync( + string downloadUrl, string destinationPath, + IProgress? progress = null, CancellationToken ct = default) + { + ConfigureHeaders(); + using var response = await _http.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead, ct); + response.EnsureSuccessStatusCode(); + + var totalBytes = response.Content.Headers.ContentLength; + await using var stream = await response.Content.ReadAsStreamAsync(ct); + await using var file = File.Create(destinationPath); + + var buffer = new byte[81920]; + long downloaded = 0; + int read; + + while ((read = await stream.ReadAsync(buffer, ct)) > 0) + { + await file.WriteAsync(buffer.AsMemory(0, read), ct); + downloaded += read; + if (totalBytes.HasValue) + progress?.Report((double)downloaded / totalBytes.Value); + } + + return destinationPath; + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private void ConfigureHeaders() + { + _http.DefaultRequestHeaders.Remove("apikey"); + _http.DefaultRequestHeaders.Add("apikey", _settings.Settings.NexusApiKey); + _http.DefaultRequestHeaders.UserAgent.TryParseAdd("DVModManager/1.0"); + } + + private static int? ExtractNexusModId(string? homePage) + { + if (string.IsNullOrEmpty(homePage)) return null; + var match = Regex.Match(homePage, @"nexusmods\.com/[^/]+/mods/(\d+)", RegexOptions.IgnoreCase); + return match.Success && int.TryParse(match.Groups[1].Value, out var id) ? id : null; + } + + private static bool IsNewerVersion(string current, string latest) + { + if (Version.TryParse(Normalize(current), out var c) && + Version.TryParse(Normalize(latest), out var l)) + return l > c; + + // Cannot compare reliably — do not report as an update + return false; + } + + private static string Normalize(string v) => v.TrimStart('v', 'V').Trim(); + + // ── Nexus DTO ───────────────────────────────────────────────────────────── + + private sealed class NexusModDto + { + public string? Version { get; set; } + + [System.Text.Json.Serialization.JsonPropertyName("updated_time")] + public long? UpdatedTime { get; set; } + } +} diff --git a/DVModManager/Services/ProfileService.cs b/DVModManager/Services/ProfileService.cs new file mode 100644 index 0000000..0e59d97 --- /dev/null +++ b/DVModManager/Services/ProfileService.cs @@ -0,0 +1,109 @@ +using System.Text.Json; +using DVModManager.Models; + +namespace DVModManager.Services; + +public class ProfileService : IProfileService +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + public async Task> GetProfilesAsync(string profilesPath) + { + if (!Directory.Exists(profilesPath)) return []; + + var profiles = new List(); + foreach (var file in Directory.GetFiles(profilesPath, "*.json")) + { + try + { + var json = await File.ReadAllTextAsync(file); + var profile = JsonSerializer.Deserialize(json, JsonOptions); + if (profile != null) profiles.Add(profile); + } + catch { /* skip corrupt profiles */ } + } + + return profiles.OrderBy(p => p.Name).ToList(); + } + + public async Task GetProfileAsync(string name, string profilesPath) + { + var filePath = GetProfileFilePath(name, profilesPath); + if (!File.Exists(filePath)) return null; + + try + { + var json = await File.ReadAllTextAsync(filePath); + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch { return null; } + } + + public async Task SaveProfileAsync(ModProfile profile, string profilesPath) + { + Directory.CreateDirectory(profilesPath); + profile.LastModifiedAt = DateTime.UtcNow; + var json = JsonSerializer.Serialize(profile, JsonOptions); + await File.WriteAllTextAsync(GetProfileFilePath(profile.Name, profilesPath), json); + } + + public Task DeleteProfileAsync(string name, string profilesPath) + { + var filePath = GetProfileFilePath(name, profilesPath); + if (File.Exists(filePath)) File.Delete(filePath); + return Task.CompletedTask; + } + + public async Task ExportProfileAsync(ModProfile profile, string destinationFilePath) + { + var json = JsonSerializer.Serialize(profile, JsonOptions); + await File.WriteAllTextAsync(destinationFilePath, json); + return destinationFilePath; + } + + public async Task ImportProfileAsync(string sourceFilePath) + { + var json = await File.ReadAllTextAsync(sourceFilePath); + return JsonSerializer.Deserialize(json, JsonOptions) + ?? throw new InvalidDataException("File is not a valid profile."); + } + + public ProfileDiff ComputeDiff(ModProfile profile, IReadOnlyList currentMods) + { + // Use last-write-wins to tolerate duplicate mod IDs (e.g. same mod in both + // Mods/ and Mods.inactive/ simultaneously after a failed move). + var currentById = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var m in currentMods) + if (!string.IsNullOrEmpty(m.Id)) + currentById[m.Id] = m; + var toActivate = new List(); + var toDeactivate = new List(); + var toRollback = new List<(string, string, string)>(); + + foreach (var entry in profile.Mods) + { + if (!currentById.TryGetValue(entry.ModId, out var current)) continue; + + if (entry.IsActive && !current.IsActive) + toActivate.Add(entry.ModId); + else if (!entry.IsActive && current.IsActive) + toDeactivate.Add(entry.ModId); + + if (!string.IsNullOrEmpty(entry.Version) && entry.Version != current.Version) + toRollback.Add((entry.ModId, current.Version, entry.Version)); + } + + // Deactivate any mods that aren't in the profile at all + var profileModIds = profile.Mods.Select(m => m.ModId).ToHashSet(); + foreach (var mod in currentMods.Where(m => m.IsActive && !profileModIds.Contains(m.Id))) + toDeactivate.Add(mod.Id); + + return new ProfileDiff(toActivate, toDeactivate, toRollback); + } + + private static string GetProfileFilePath(string name, string profilesPath) + { + var safeName = string.Concat(name.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c)); + return Path.Combine(profilesPath, safeName + ".json"); + } +} diff --git a/DVModManager/Services/SettingsService.cs b/DVModManager/Services/SettingsService.cs new file mode 100644 index 0000000..5554443 --- /dev/null +++ b/DVModManager/Services/SettingsService.cs @@ -0,0 +1,41 @@ +using System.Text.Json; +using DVModManager.Models; + +namespace DVModManager.Services; + +public class SettingsService : ISettingsService +{ + private static readonly string SettingsFilePath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "DVModManager", "settings.json"); + + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + public AppSettings Settings { get; private set; } = new(); + + public async Task LoadAsync() + { + try + { + if (!File.Exists(SettingsFilePath)) + { + Settings = new AppSettings(); + return; + } + + var json = await File.ReadAllTextAsync(SettingsFilePath); + Settings = JsonSerializer.Deserialize(json, JsonOptions) ?? new AppSettings(); + } + catch + { + Settings = new AppSettings(); + } + } + + public async Task SaveAsync() + { + Directory.CreateDirectory(Path.GetDirectoryName(SettingsFilePath)!); + var json = JsonSerializer.Serialize(Settings, JsonOptions); + await File.WriteAllTextAsync(SettingsFilePath, json); + } +} diff --git a/DVModManager/Services/UpdateService.cs b/DVModManager/Services/UpdateService.cs new file mode 100644 index 0000000..1f990b3 --- /dev/null +++ b/DVModManager/Services/UpdateService.cs @@ -0,0 +1,45 @@ +using DVModManager.Models; +using Microsoft.Extensions.Logging; + +namespace DVModManager.Services; + +public class UpdateService : IUpdateService +{ + private readonly IGitHubModsService _github; + private readonly INexusModsService _nexus; + private readonly ILogger _logger; + + public UpdateService(IGitHubModsService github, INexusModsService nexus, ILogger logger) + { + _github = github; + _nexus = nexus; + _logger = logger; + } + + public async Task> CheckAllUpdatesAsync( + IReadOnlyList mods, CancellationToken ct = default) + { + var tasks = mods.Select(m => CheckUpdateAsync(m, ct)); + var results = await Task.WhenAll(tasks); + return results.Where(r => r != null).Select(r => r!).ToList(); + } + + public async Task CheckUpdateAsync(ModInfo mod, CancellationToken ct = default) + { + // Try GitHub first (free, no key required) + if (!string.IsNullOrEmpty(mod.Repository)) + { + var ghUpdate = await _github.CheckUpdateAsync(mod, ct); + if (ghUpdate != null) return ghUpdate; + } + + // Try Nexus if API key is configured + if (_nexus.IsConfigured && !string.IsNullOrEmpty(mod.HomePage)) + { + var nexusUpdate = await _nexus.CheckUpdateAsync(mod, ct); + if (nexusUpdate != null) return nexusUpdate; + } + + return null; + } +} diff --git a/DVModManager/Services/VersionCacheService.cs b/DVModManager/Services/VersionCacheService.cs new file mode 100644 index 0000000..b671eaa --- /dev/null +++ b/DVModManager/Services/VersionCacheService.cs @@ -0,0 +1,122 @@ +using System.IO.Compression; +using System.Text.Json; +using DVModManager.Models; + +namespace DVModManager.Services; + +public class VersionCacheService : IVersionCacheService +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true }; + + public async Task ArchiveCurrentVersionAsync(ModInfo mod, string storagePath) + { + if (!Directory.Exists(mod.FolderPath)) return; + + var modVersionDir = Path.Combine(storagePath, "versions", mod.Id); + Directory.CreateDirectory(modVersionDir); + + var safeVersion = mod.Version.Replace(' ', '_').Replace('/', '_'); + var archivePath = Path.Combine(modVersionDir, $"{safeVersion}.zip"); + + // Overwrite any existing archive for this version + if (File.Exists(archivePath)) File.Delete(archivePath); + + await Task.Run(() => ZipFile.CreateFromDirectory(mod.FolderPath, archivePath, CompressionLevel.Optimal, false)); + + // Update the manifest + var versions = (await GetVersionHistoryAsync(mod.Id, storagePath)).ToList(); + if (!versions.Any(v => v.Version == mod.Version)) + { + versions.Insert(0, new ModVersion + { + ModId = mod.Id, + Version = mod.Version, + ArchivePath = archivePath, + Source = "cached", + ArchivedAt = DateTime.UtcNow, + ArchiveSizeBytes = new FileInfo(archivePath).Length + }); + } + + await SaveManifestAsync(mod.Id, modVersionDir, versions); + } + + public async Task> GetVersionHistoryAsync(string modId, string storagePath) + { + var manifestPath = GetManifestPath(modId, storagePath); + if (!File.Exists(manifestPath)) return []; + + try + { + var json = await File.ReadAllTextAsync(manifestPath); + return JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + } + catch { return []; } + } + + public async Task GetVersionArchivePathAsync(string modId, string version, string storagePath) + { + var versions = await GetVersionHistoryAsync(modId, storagePath); + var entry = versions.FirstOrDefault(v => v.Version == version); + return entry?.ArchivePath is { } path && File.Exists(path) ? path : null; + } + + public async Task DeleteVersionAsync(string modId, string version, string storagePath) + { + var versions = (await GetVersionHistoryAsync(modId, storagePath)).ToList(); + var entry = versions.FirstOrDefault(v => v.Version == version); + if (entry == null) return; + + if (File.Exists(entry.ArchivePath)) File.Delete(entry.ArchivePath); + versions.Remove(entry); + + var manifestDir = Path.Combine(storagePath, "versions", modId); + await SaveManifestAsync(modId, manifestDir, versions); + } + + public Task GetCacheSizeAsync(string storagePath) + { + var versionsDir = Path.Combine(storagePath, "versions"); + if (!Directory.Exists(versionsDir)) return Task.FromResult(0L); + + var size = Directory.GetFiles(versionsDir, "*.zip", SearchOption.AllDirectories) + .Sum(f => new FileInfo(f).Length); + return Task.FromResult(size); + } + + public async Task PruneCacheAsync(string storagePath, long maxSizeBytes) + { + var versionsDir = Path.Combine(storagePath, "versions"); + if (!Directory.Exists(versionsDir)) return; + + // Collect all version entries across all mods, sorted oldest first + var allVersions = new List<(string ModId, ModVersion Version, string ManifestDir)>(); + foreach (var modDir in Directory.GetDirectories(versionsDir)) + { + var modId = Path.GetFileName(modDir); + var versions = (await GetVersionHistoryAsync(modId, storagePath)).ToList(); + foreach (var v in versions) + allVersions.Add((modId, v, modDir)); + } + + allVersions.Sort((a, b) => a.Version.ArchivedAt.CompareTo(b.Version.ArchivedAt)); + + long totalSize = allVersions.Sum(x => x.Version.ArchiveSizeBytes); + foreach (var (modId, version, _) in allVersions) + { + if (totalSize <= maxSizeBytes) break; + totalSize -= version.ArchiveSizeBytes; + await DeleteVersionAsync(modId, version.Version, storagePath); + } + } + + private static string GetManifestPath(string modId, string storagePath) => + Path.Combine(storagePath, "versions", modId, "versions.json"); + + private static async Task SaveManifestAsync(string modId, string modVersionDir, List versions) + { + Directory.CreateDirectory(modVersionDir); + var json = JsonSerializer.Serialize(versions, JsonOptions); + await File.WriteAllTextAsync(Path.Combine(modVersionDir, "versions.json"), json); + } +} diff --git a/DVModManager/ViewModels/MainWindowViewModel.cs b/DVModManager/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..c3b2736 --- /dev/null +++ b/DVModManager/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,743 @@ +using System.Collections.ObjectModel; +using Avalonia.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using DVModManager.Models; +using DVModManager.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace DVModManager.ViewModels; + +public partial class MainWindowViewModel : ViewModelBase +{ + // ── Services ────────────────────────────────────────────────────────────── + private readonly ISettingsService _settings; + private readonly IGameDetectionService _gameDetection; + private readonly IModDiscoveryService _modDiscovery; + private readonly IModInstallService _modInstall; + private readonly IVersionCacheService _versionCache; + private readonly IProfileService _profileService; + private readonly IUpdateService _updateService; + private readonly IDialogService _dialogService; + + // ── Observable state ────────────────────────────────────────────────────── + [ObservableProperty] private ObservableCollection _availableMods = []; + [ObservableProperty] private ObservableCollection _activeMods = []; + [ObservableProperty] private ModItemViewModel? _selectedMod; + [ObservableProperty] private bool _isGameRunning; + [ObservableProperty] private string _statusMessage = "Ready."; + [ObservableProperty] private bool _isBusy; + [ObservableProperty] private string _busyMessage = ""; + [ObservableProperty] private string _selectedProfileName = "Default"; + [ObservableProperty] private ObservableCollection _profileNames = []; + + // ── Version history for selected mod ────────────────────────────────────── + [ObservableProperty] private IReadOnlyList _selectedModVersionHistory = []; + [ObservableProperty] private ModVersion? _selectedHistoryVersion; + + partial void OnSelectedModChanged(ModItemViewModel? value) => + _ = LoadVersionHistoryForSelectedAsync(value); + + private async Task LoadVersionHistoryForSelectedAsync(ModItemViewModel? mod) + { + if (mod == null) { SelectedModVersionHistory = []; return; } + SelectedModVersionHistory = await _versionCache.GetVersionHistoryAsync( + mod.Id, _settings.Settings.StoragePath); + } + + // ── Filter state ────────────────────────────────────────────────────────────── + [ObservableProperty] private string _availableFilter = ""; + [ObservableProperty] private string _activeFilter = ""; + [ObservableProperty] private ObservableCollection _filteredAvailableMods = []; + [ObservableProperty] private ObservableCollection _filteredActiveMods = []; + + public MainWindowViewModel( + ISettingsService settings, + IGameDetectionService gameDetection, + IModDiscoveryService modDiscovery, + IModInstallService modInstall, + IVersionCacheService versionCache, + IProfileService profileService, + IUpdateService updateService, + IDialogService dialogService) + { + _settings = settings; + _gameDetection = gameDetection; + _modDiscovery = modDiscovery; + _modInstall = modInstall; + _versionCache = versionCache; + _profileService = profileService; + _updateService = updateService; + _dialogService = dialogService; + + _gameDetection.GameRunningChanged += OnGameRunningChanged; + _modDiscovery.ModsChanged += OnModsChangedExternally; + + IsGameRunning = _gameDetection.IsGameRunning(); + } + + partial void OnAvailableFilterChanged(string value) => ApplyFilters(); + partial void OnActiveFilterChanged(string value) => ApplyFilters(); + + partial void OnSelectedProfileNameChanged(string value) + { + // Auto-apply when user picks an existing saved profile from the dropdown + if (IsBusy || IsGameRunning) return; + if (ProfileNames.Contains(value) && value != _settings.Settings.ActiveProfileName) + _ = ApplyProfileAsync(value); + } + + private void ApplyFilters() + { + static bool Matches(ModItemViewModel vm, string filter) => + string.IsNullOrEmpty(filter) || + vm.DisplayName.Contains(filter, StringComparison.OrdinalIgnoreCase) || + vm.Author.Contains(filter, StringComparison.OrdinalIgnoreCase); + + FilteredAvailableMods = new ObservableCollection( + AvailableMods.Where(m => Matches(m, AvailableFilter))); + FilteredActiveMods = new ObservableCollection( + ActiveMods.Where(m => Matches(m, ActiveFilter))); + } + + // ── Startup ─────────────────────────────────────────────────────────────── + + public async Task InitializeAsync() + { + try + { + await _settings.LoadAsync(); + SelectedProfileName = _settings.Settings.ActiveProfileName; + + if (string.IsNullOrEmpty(_settings.Settings.GamePath)) + { + _settings.Settings.GamePath = _gameDetection.DetectGamePath(); + if (_settings.Settings.GamePath != null) + { + StatusMessage = $"Detected game at: {_settings.Settings.GamePath}"; + await _settings.SaveAsync(); + } + else + { + StatusMessage = "Game not found. Please set the game path in Settings."; + await PromptGamePathAsync(); + return; + } + } + + await RefreshModsAsync(); + await RefreshProfileListAsync(); + + if (_settings.Settings.AutoCheckUpdatesOnStartup) + _ = CheckUpdatesAsync(); + + _modDiscovery.StartWatching(_settings.Settings.GamePath!); + } + catch (Exception ex) + { + StatusMessage = $"Startup error: {ex.Message}"; + } + } + + // ── Mod operations ──────────────────────────────────────────────────────── + + [RelayCommand(CanExecute = nameof(CanModify))] + private async Task ActivateSelectedModAsync() + { + if (SelectedMod == null || _settings.Settings.GamePath == null) return; + + // Capture before any await — ApplyFilters can null SelectedMod + var target = SelectedMod; + var displayName = target.DisplayName; + + // Try to auto-activate any inactive dependencies first + var trulyMissing = await AutoActivateDependenciesAsync(target); + if (trulyMissing.Count > 0) + { + target.State = ModState.MissingDependency; + target.HasMissingDependency = true; + StatusMessage = $"Missing dependencies for {displayName}: {string.Join(", ", trulyMissing)}"; + return; + } + + SetBusy($"Activating {displayName}..."); + var success = await _modInstall.ActivateModAsync(target.ModInfo, _settings.Settings.GamePath); + ClearBusy(); + + if (success) + { + target.SyncFromModel(); + MoveToActive(target); + StatusMessage = $"Activated: {displayName}"; + } + else + { + StatusMessage = $"Failed to activate: {displayName}"; + } + } + + [RelayCommand(CanExecute = nameof(CanModify))] + private async Task DeactivateSelectedModAsync() + { + if (SelectedMod == null || _settings.Settings.GamePath == null) return; + + SetBusy($"Deactivating {SelectedMod.DisplayName}..."); + var success = await _modInstall.DeactivateModAsync(SelectedMod.ModInfo, _settings.Settings.GamePath); + ClearBusy(); + + var displayName = SelectedMod.DisplayName; + if (success) + { + SelectedMod.SyncFromModel(); + MoveToInactive(SelectedMod); + StatusMessage = $"Deactivated: {displayName}"; + } + else + { + StatusMessage = $"Failed to deactivate: {displayName}"; + } + } + + [RelayCommand(CanExecute = nameof(CanModify))] + private async Task InstallModFromFileAsync() + { + var path = await _dialogService.OpenFileAsync("Install Mod Archive", "Mod Archives", ["zip"]); + if (path == null || _settings.Settings.GamePath == null) return; + + SetBusy("Installing mod..."); + var mod = await _modInstall.InstallFromArchiveAsync( + path, _settings.Settings.GamePath, _settings.Settings.StoragePath); + ClearBusy(); + + if (mod != null) + { + await RefreshModsAsync(); + StatusMessage = $"Installed: {mod.EffectiveDisplayName}"; + } + else + { + StatusMessage = "Install failed. Ensure the archive contains Info.json."; + } + } + + [RelayCommand(CanExecute = nameof(CanModify))] + private async Task UninstallSelectedModAsync() + { + if (SelectedMod == null || _settings.Settings.GamePath == null) return; + + var displayName = SelectedMod.DisplayName; + var confirmed = await _dialogService.ConfirmAsync( + "Uninstall Mod", + $"Remove '{displayName}'? The current version will be archived for rollback, then deleted."); + + if (!confirmed) return; + + SetBusy($"Uninstalling {displayName}..."); + var success = await _modInstall.UninstallModAsync( + SelectedMod.ModInfo, _settings.Settings.GamePath, _settings.Settings.StoragePath, hardDelete: false); + ClearBusy(); + + if (success) + { + await RefreshModsAsync(); + StatusMessage = $"Uninstalled: {displayName}"; + } + } + + // ── Updates ─────────────────────────────────────────────────────────────── + + [RelayCommand] + private async Task CheckUpdatesAsync() + { + var allMods = AvailableMods.Concat(ActiveMods).Select(v => v.ModInfo).ToList(); + if (allMods.Count == 0) return; + + SetBusy("Checking for updates..."); + var updates = await _updateService.CheckAllUpdatesAsync(allMods); + ClearBusy(); + + foreach (var update in updates) + { + var vm = AvailableMods.Concat(ActiveMods).FirstOrDefault(m => m.Id == update.ModId); + vm?.ApplyUpdate(update); + } + + StatusMessage = updates.Count > 0 + ? $"{updates.Count} update(s) available." + : "All mods are up to date."; + } + + [RelayCommand(CanExecute = nameof(CanModify))] + private async Task UpdateSelectedModAsync() + { + if (SelectedMod?.ModInfo.PendingUpdate == null || _settings.Settings.GamePath == null) return; + + var displayName = SelectedMod.DisplayName; + var update = SelectedMod.ModInfo.PendingUpdate; + + // Nexus mods (and GitHub releases without a direct asset URL) require manual download + bool canAutoDownload = update.Source == "github" && !string.IsNullOrEmpty(update.DownloadUrl); + if (!canAutoDownload) + { + var url = update.ChangelogUrl ?? update.DownloadUrl; + if (!string.IsNullOrEmpty(url)) + Helpers.PlatformHelper.Open(url); + StatusMessage = $"Opened mod page for {displayName} v{update.LatestVersion}"; + return; + } + + SetBusy($"Updating {displayName} to v{update.LatestVersion}..."); + var progress = new Progress(p => + BusyMessage = $"Updating {displayName}… {p:P0}"); + var success = await _modInstall.UpdateModAsync( + SelectedMod.ModInfo, update, _settings.Settings.GamePath, _settings.Settings.StoragePath, progress); + ClearBusy(); + + if (success) + { + await RefreshModsAsync(); + StatusMessage = $"Updated {displayName} to v{update.LatestVersion}"; + } + else + { + StatusMessage = $"Update failed for {displayName}"; + } + } + + [RelayCommand(CanExecute = nameof(CanModify))] + private async Task UpdateAllModsAsync() + { + var modsWithUpdates = AvailableMods.Concat(ActiveMods) + .Where(m => m.HasUpdate + && m.ModInfo.PendingUpdate?.Source == "github" + && !string.IsNullOrEmpty(m.ModInfo.PendingUpdate?.DownloadUrl)) + .ToList(); + + if (modsWithUpdates.Count == 0) + { + StatusMessage = "No downloadable updates available."; + return; + } + + var confirmed = await _dialogService.ConfirmAsync("Update All", + $"Update {modsWithUpdates.Count} mod(s)? Current versions will be archived."); + if (!confirmed) return; + + // Backup first + if (_settings.Settings.GamePath != null && _settings.Settings.BackupBeforeChanges) + { + SetBusy("Creating backup..."); + await _modInstall.BackupModsFolderAsync(_settings.Settings.GamePath, _settings.Settings.StoragePath); + } + + int updated = 0; + foreach (var mod in modsWithUpdates) + { + var modName = mod.DisplayName; + SetBusy($"Updating {modName}... ({updated + 1}/{modsWithUpdates.Count})"); + var progress = new Progress(p => + BusyMessage = $"Updating {modName}… {p:P0} ({updated + 1}/{modsWithUpdates.Count})"); + var success = await _modInstall.UpdateModAsync( + mod.ModInfo, mod.ModInfo.PendingUpdate!, _settings.Settings.GamePath!, _settings.Settings.StoragePath, progress); + if (success) updated++; + } + + ClearBusy(); + await RefreshModsAsync(); + StatusMessage = $"Updated {updated}/{modsWithUpdates.Count} mods."; + } + + // ── Rollback ────────────────────────────────────────────────────────────── + + [RelayCommand(CanExecute = nameof(CanModify))] + private async Task RollbackSelectedModAsync(string version) + { + if (SelectedMod == null || _settings.Settings.GamePath == null) return; + + var displayName = SelectedMod.DisplayName; + var modId = SelectedMod.Id; + SetBusy($"Rolling back {displayName} to v{version}..."); + var success = await _modInstall.RollbackToVersionAsync( + modId, version, _settings.Settings.GamePath, _settings.Settings.StoragePath); + ClearBusy(); + + if (success) + { + await RefreshModsAsync(); + StatusMessage = $"Rolled back {displayName} to v{version}"; + } + else + { + StatusMessage = $"Rollback failed for {displayName}"; + } + } + + // ── Profiles ────────────────────────────────────────────────────────────── + + [RelayCommand] + private async Task SaveCurrentProfileAsync() + { + var allMods = AvailableMods.Concat(ActiveMods).Select(v => v.ModInfo).ToList(); + var profile = new ModProfile + { + Name = SelectedProfileName, + Mods = allMods.Select(m => new ProfileModEntry + { + ModId = m.Id, + Version = m.Version, + IsActive = m.IsActive + }).ToList() + }; + + await _profileService.SaveProfileAsync(profile, _settings.Settings.ProfilesPath); + _settings.Settings.ActiveProfileName = SelectedProfileName; + await _settings.SaveAsync(); + await RefreshProfileListAsync(); + StatusMessage = $"Profile '{SelectedProfileName}' saved."; + } + + [RelayCommand(CanExecute = nameof(CanModify))] + private async Task DeactivateAllModsAsync() + { + if (_settings.Settings.GamePath == null) return; + var active = ActiveMods.ToList(); + if (active.Count == 0) { StatusMessage = "No active mods."; return; } + + var confirmed = await _dialogService.ConfirmAsync("Unload All Mods", + $"Deactivate all {active.Count} active mod(s)?"); + if (!confirmed) return; + + SetBusy("Unloading all mods..."); + int done = 0; + foreach (var vm in active) + { + BusyMessage = $"Deactivating {vm.DisplayName}... ({done + 1}/{active.Count})"; + var ok = await _modInstall.DeactivateModAsync(vm.ModInfo, _settings.Settings.GamePath!); + if (ok) done++; + } + ClearBusy(); + await RefreshModsAsync(); + StatusMessage = $"Deactivated {done}/{active.Count} mods."; + } + + [RelayCommand(CanExecute = nameof(CanModify))] + private async Task ApplyProfileAsync(string profileName) + { + if (_settings.Settings.GamePath == null) return; + + var profile = await _profileService.GetProfileAsync(profileName, _settings.Settings.ProfilesPath); + if (profile == null) return; + + var allMods = AvailableMods.Concat(ActiveMods).Select(v => v.ModInfo).ToList(); + var diff = _profileService.ComputeDiff(profile, allMods); + + if (diff.ToActivate.Count == 0 && diff.ToDeactivate.Count == 0 && diff.ToRollback.Count == 0) + { + StatusMessage = "Profile is already applied."; + return; + } + + // Backup before bulk change + if (_settings.Settings.BackupBeforeChanges) + { + SetBusy("Creating backup..."); + await _modInstall.BackupModsFolderAsync(_settings.Settings.GamePath, _settings.Settings.StoragePath); + } + + // Apply changes + foreach (var id in diff.ToDeactivate) + { + var mod = allMods.FirstOrDefault(m => m.Id == id); + if (mod != null && mod.IsActive) + await _modInstall.DeactivateModAsync(mod, _settings.Settings.GamePath); + } + + foreach (var (modId, _, toVersion) in diff.ToRollback) + { + await _modInstall.RollbackToVersionAsync( + modId, toVersion, _settings.Settings.GamePath, _settings.Settings.StoragePath); + } + + foreach (var id in diff.ToActivate) + { + var mod = allMods.FirstOrDefault(m => m.Id == id); + if (mod != null && !mod.IsActive) + await _modInstall.ActivateModAsync(mod, _settings.Settings.GamePath); + } + + ClearBusy(); + // Set ActiveProfileName FIRST so OnSelectedProfileNameChanged sees + // value == ActiveProfileName and does not re-enter ApplyProfileAsync. + _settings.Settings.ActiveProfileName = profileName; + SelectedProfileName = profileName; + await _settings.SaveAsync(); + await RefreshModsAsync(); + StatusMessage = $"Applied profile '{profileName}'."; + } + + // ── Refresh ─────────────────────────────────────────────────────────────── + + [RelayCommand] + private async Task RefreshModsAsync() + { + if (_settings.Settings.GamePath == null) + { + StatusMessage = "Game path not set. Open Settings to configure."; + return; + } + + var modsDir = Path.Combine(_settings.Settings.GamePath, "Mods"); + if (!Directory.Exists(modsDir)) + { + StatusMessage = $"Mods folder not found: {modsDir}"; + return; + } + + IReadOnlyList mods; + try + { + mods = await _modDiscovery.ScanAllModsAsync(_settings.Settings.GamePath); + } + catch (Exception ex) + { + StatusMessage = $"Scan error: {ex.Message}"; + return; + } + + await Dispatcher.UIThread.InvokeAsync(() => + { + AvailableMods.Clear(); + ActiveMods.Clear(); + + foreach (var mod in mods.OrderBy(m => m.EffectiveDisplayName)) + { + var vm = new ModItemViewModel(mod); + if (mod.IsActive) ActiveMods.Add(vm); + else AvailableMods.Add(vm); + } + + ApplyFilters(); + + var active = mods.Count(m => m.IsActive); + var inactive = mods.Count(m => !m.IsActive); + StatusMessage = mods.Count == 0 + ? $"No mods found in {modsDir}" + : $"Found {mods.Count} mod(s) — {active} active, {inactive} inactive."; + }); + } + + private async Task RefreshProfileListAsync() + { + var profiles = await _profileService.GetProfilesAsync(_settings.Settings.ProfilesPath); + await Dispatcher.UIThread.InvokeAsync(() => + { + ProfileNames.Clear(); + if (!profiles.Any(p => p.Name == "Default")) + ProfileNames.Add("Default"); + foreach (var p in profiles) ProfileNames.Add(p.Name); + }); + } + + // ── Settings ────────────────────────────────────────────────────────────── + + [RelayCommand] + private async Task OpenSettingsAsync() + { + var vm = App.Services.GetRequiredService(); + vm.Load(_settings.Settings); + var dialog = new DVModManager.Views.SettingsDialog { DataContext = vm }; + await dialog.ShowDialog(GetMainWindow()); + if (vm.Saved) + { + var previousGamePath = _settings.Settings.GamePath; + vm.Apply(_settings.Settings); // copy UI values → AppSettings + await _settings.SaveAsync(); + await RefreshModsAsync(); + + // Restart file watchers if the game path changed + if (_settings.Settings.GamePath != null && + _settings.Settings.GamePath != previousGamePath) + { + _modDiscovery.StartWatching(_settings.Settings.GamePath); + } + } + } + + [RelayCommand] + private async Task OpenProfilesAsync() + { + var vm = App.Services.GetRequiredService(); + await vm.LoadProfilesAsync(_settings.Settings.ProfilesPath); + var dialog = new DVModManager.Views.ProfileDialog { DataContext = vm }; + await dialog.ShowDialog(GetMainWindow()); + if (vm.Applied && vm.AppliedProfileName != null) + await ApplyProfileAsync(vm.AppliedProfileName); + } + + // ── Version history for selected mod ───────────────────────────────────── + + public async Task> GetVersionHistoryForSelectedAsync() + { + if (SelectedMod == null) return []; + return await _versionCache.GetVersionHistoryAsync(SelectedMod.Id, _settings.Settings.StoragePath); + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private bool CanModify() => !IsGameRunning && !IsBusy; + + private void OnGameRunningChanged(object? sender, bool running) + { + Dispatcher.UIThread.Post(() => + { + IsGameRunning = running; + StatusMessage = running ? "Game is running. Mod changes are locked." : "Game stopped. Mod changes unlocked."; + ActivateSelectedModCommand.NotifyCanExecuteChanged(); + DeactivateSelectedModCommand.NotifyCanExecuteChanged(); + InstallModFromFileCommand.NotifyCanExecuteChanged(); + UninstallSelectedModCommand.NotifyCanExecuteChanged(); + UpdateSelectedModCommand.NotifyCanExecuteChanged(); + UpdateAllModsCommand.NotifyCanExecuteChanged(); + ApplyProfileCommand.NotifyCanExecuteChanged(); + }); + } + + private void OnModsChangedExternally(object? sender, EventArgs e) + { + // Skip if a mod operation is already in progress — it will refresh itself when done + if (IsBusy) return; + Dispatcher.UIThread.Post(async () => await RefreshModsAsync()); + } + + private void MoveToActive(ModItemViewModel vm) + { + AvailableMods.Remove(vm); + ActiveMods.Add(vm); + ApplyFilters(); + } + + private void MoveToInactive(ModItemViewModel vm) + { + ActiveMods.Remove(vm); + AvailableMods.Add(vm); + ApplyFilters(); + } + + /// + /// For each required dependency: if it is already active → skip. + /// If it is inactive → activate it automatically. + /// Returns the list of dependency IDs that could not be found at all. + /// + private async Task> AutoActivateDependenciesAsync(ModItemViewModel vm) + { + if (_settings.Settings.GamePath == null) return []; + + var activeIds = ActiveMods.Select(m => m.Id).ToHashSet(StringComparer.OrdinalIgnoreCase); + // Use last-write-wins to handle any duplicates (e.g. mods with empty Id) + var inactiveMap = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var m in AvailableMods) + if (!string.IsNullOrEmpty(m.Id)) + inactiveMap[m.Id] = m; + + var trulyMissing = new List(); + var activated = new List(); + + foreach (var req in vm.Requirements) + { + var depId = req.Split('-')[0]; // strip optional version suffix + if (string.IsNullOrEmpty(depId) || activeIds.Contains(depId)) continue; + + if (inactiveMap.TryGetValue(depId, out var depVm)) + { + SetBusy($"Activating dependency {depVm.DisplayName}..."); + var ok = await _modInstall.ActivateModAsync(depVm.ModInfo, _settings.Settings.GamePath); + ClearBusy(); + if (ok) + { + depVm.SyncFromModel(); + activated.Add(depVm); + activeIds.Add(depId); + } + else + { + trulyMissing.Add(depId); + } + } + else + { + trulyMissing.Add(depId); + } + } + + // Move all activated dependencies in one pass (already on UI thread) + foreach (var dep in activated) + { + AvailableMods.Remove(dep); + ActiveMods.Add(dep); + } + if (activated.Count > 0) + ApplyFilters(); + + return trulyMissing; + } + + private bool ValidateDependencies(ModItemViewModel vm) + { + var allActive = ActiveMods.Select(m => m.Id).ToHashSet(); + var missing = vm.Requirements + .Where(r => !allActive.Contains(r.Split('-')[0])) + .ToList(); + + if (missing.Count == 0) return true; + + vm.State = ModState.MissingDependency; + vm.HasMissingDependency = true; + StatusMessage = $"Missing dependencies for {vm.DisplayName}: {string.Join(", ", missing)}"; + return false; + } + + private async Task PromptGamePathAsync() + { + var path = await _dialogService.PickFolderAsync("Select Derail Valley installation folder"); + if (path == null || !_gameDetection.ValidateGamePath(path)) + { + await _dialogService.ShowMessageAsync("Error", "Invalid game path — could not find DerailValley_Data folder."); + return; + } + _settings.Settings.GamePath = path; + await _settings.SaveAsync(); + await RefreshModsAsync(); + } + + private void SetBusy(string message) + { + IsBusy = true; + BusyMessage = message; + StatusMessage = message; + ActivateSelectedModCommand.NotifyCanExecuteChanged(); + DeactivateSelectedModCommand.NotifyCanExecuteChanged(); + } + + private void ClearBusy() + { + IsBusy = false; + BusyMessage = ""; + ActivateSelectedModCommand.NotifyCanExecuteChanged(); + DeactivateSelectedModCommand.NotifyCanExecuteChanged(); + } + + private static Avalonia.Controls.Window GetMainWindow() + { + if (App.Services.GetService(typeof(DVModManager.Views.MainWindow)) is DVModManager.Views.MainWindow w) return w; + return (Avalonia.Controls.Window)Avalonia.Application.Current! + .ApplicationLifetime.Cast()! + .MainWindow!; + } +} + +// Extension for null-safe cast +file static class NullExtensions +{ + public static T Cast(this object? o) where T : class => + o as T ?? throw new InvalidCastException($"Cannot cast to {typeof(T).Name}"); +} diff --git a/DVModManager/ViewModels/ModItemViewModel.cs b/DVModManager/ViewModels/ModItemViewModel.cs new file mode 100644 index 0000000..95ce510 --- /dev/null +++ b/DVModManager/ViewModels/ModItemViewModel.cs @@ -0,0 +1,84 @@ +using Avalonia.Media; +using CommunityToolkit.Mvvm.ComponentModel; +using DVModManager.Models; + +namespace DVModManager.ViewModels; + +public partial class ModItemViewModel : ViewModelBase +{ + private readonly ModInfo _modInfo; + + public ModItemViewModel(ModInfo modInfo) + { + _modInfo = modInfo; + SyncFromModel(); + } + + [ObservableProperty] private string _id = ""; + [ObservableProperty] private string _displayName = ""; + [ObservableProperty] private string _author = ""; + [ObservableProperty] private string _version = ""; + [ObservableProperty] private bool _isActive; + [ObservableProperty] private ModState _state; + [ObservableProperty] private bool _hasUpdate; + [ObservableProperty] private string? _updateVersion; + [ObservableProperty] private bool _hasMissingDependency; + [ObservableProperty] private bool _hasMetadata; + [ObservableProperty] private string? _homePage; + [ObservableProperty] private string? _repository; + [ObservableProperty] private string? _description; + [ObservableProperty] private string[] _requirements = []; + + public ModInfo ModInfo => _modInfo; + + public string StatusLabel => State switch + { + ModState.UpdateAvailable => $"Update available → {UpdateVersion}", + ModState.MissingDependency => "Missing dependency", + ModState.NoMetadata => "No metadata", + ModState.Active => "Active", + _ => "Inactive" + }; + + public IBrush StatusBrush => State switch + { + ModState.Active => new SolidColorBrush(Color.Parse("#2ecc71")), + ModState.UpdateAvailable => new SolidColorBrush(Color.Parse("#f39c12")), + ModState.MissingDependency or ModState.NoMetadata => new SolidColorBrush(Color.Parse("#e74c3c")), + _ => new SolidColorBrush(Color.Parse("#7f8c8d")) + }; + + public void ApplyUpdate(ModUpdateInfo update) + { + HasUpdate = true; + UpdateVersion = update.LatestVersion; + _modInfo.PendingUpdate = update; + State = ModState.UpdateAvailable; + OnPropertyChanged(nameof(StatusLabel)); + OnPropertyChanged(nameof(StatusBrush)); + } + + public void SyncFromModel() + { + Id = _modInfo.Id; + DisplayName = _modInfo.EffectiveDisplayName; + Author = _modInfo.Author; + Version = _modInfo.Version; + IsActive = _modInfo.IsActive; + State = _modInfo.State; + HasMetadata = _modInfo.HasMetadata; + HomePage = _modInfo.HomePage; + Repository = _modInfo.Repository; + Description = _modInfo.Description; + Requirements = _modInfo.Requirements; + + if (_modInfo.PendingUpdate != null) + { + HasUpdate = true; + UpdateVersion = _modInfo.PendingUpdate.LatestVersion; + } + + OnPropertyChanged(nameof(StatusLabel)); + OnPropertyChanged(nameof(StatusBrush)); + } +} diff --git a/DVModManager/ViewModels/ProfileViewModel.cs b/DVModManager/ViewModels/ProfileViewModel.cs new file mode 100644 index 0000000..c348690 --- /dev/null +++ b/DVModManager/ViewModels/ProfileViewModel.cs @@ -0,0 +1,95 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using DVModManager.Models; +using DVModManager.Services; + +namespace DVModManager.ViewModels; + +public partial class ProfileViewModel : ViewModelBase +{ + private readonly IProfileService _profileService; + private readonly IDialogService _dialogService; + private string _profilesPath = ""; + + [ObservableProperty] private ObservableCollection _profiles = []; + [ObservableProperty] private ModProfile? _selectedProfile; + [ObservableProperty] private string _newProfileName = ""; + + public bool Applied { get; private set; } + public string? AppliedProfileName { get; private set; } + + public event EventHandler? ProfileApplyRequested; + + public ProfileViewModel(IProfileService profileService, IDialogService dialogService) + { + _profileService = profileService; + _dialogService = dialogService; + } + + public async Task LoadProfilesAsync(string profilesPath) + { + _profilesPath = profilesPath; + var list = await _profileService.GetProfilesAsync(profilesPath); + Profiles.Clear(); + foreach (var p in list) Profiles.Add(p); + } + + [RelayCommand] + private void SelectProfile(ModProfile profile) => SelectedProfile = profile; + + [RelayCommand] + private async Task ApplySelectedAsync(Avalonia.Controls.Window dialog) + { + if (SelectedProfile == null) return; + AppliedProfileName = SelectedProfile.Name; + Applied = true; + ProfileApplyRequested?.Invoke(this, SelectedProfile.Name); + dialog.Close(); + } + + [RelayCommand] + private async Task DeleteSelectedAsync() + { + if (SelectedProfile == null) return; + var confirmed = await _dialogService.ConfirmAsync("Delete Profile", + $"Delete profile '{SelectedProfile.Name}'?"); + if (!confirmed) return; + + await _profileService.DeleteProfileAsync(SelectedProfile.Name, _profilesPath); + Profiles.Remove(SelectedProfile); + SelectedProfile = null; + } + + [RelayCommand] + private async Task ExportSelectedAsync() + { + if (SelectedProfile == null) return; + var path = await _dialogService.SaveFileAsync("Export Profile", + "JSON Profile", ["json"], SelectedProfile.Name + ".json"); + if (path == null) return; + + await _profileService.ExportProfileAsync(SelectedProfile, path); + } + + [RelayCommand] + private async Task ImportAsync() + { + var path = await _dialogService.OpenFileAsync("Import Profile", "JSON Profile", ["json"]); + if (path == null) return; + + try + { + var profile = await _profileService.ImportProfileAsync(path); + await _profileService.SaveProfileAsync(profile, _profilesPath); + await LoadProfilesAsync(_profilesPath); + } + catch (Exception ex) + { + await _dialogService.ShowMessageAsync("Import Failed", ex.Message); + } + } + + [RelayCommand] + private void Cancel(Avalonia.Controls.Window dialog) => dialog.Close(); +} diff --git a/DVModManager/ViewModels/SettingsViewModel.cs b/DVModManager/ViewModels/SettingsViewModel.cs new file mode 100644 index 0000000..6aeefd8 --- /dev/null +++ b/DVModManager/ViewModels/SettingsViewModel.cs @@ -0,0 +1,107 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using DVModManager.Models; +using DVModManager.Services; + +namespace DVModManager.ViewModels; + +public partial class SettingsViewModel : ViewModelBase +{ + private readonly IDialogService _dialogService; + private readonly IGameDetectionService _gameDetection; + + public bool Saved { get; private set; } + + [ObservableProperty] private string _gamePath = ""; + [ObservableProperty] private string _storagePath = ""; + [ObservableProperty] private string _nexusApiKey = ""; + [ObservableProperty] private string _gitHubToken = ""; + [ObservableProperty] private bool _autoCheckUpdates = true; + [ObservableProperty] private bool _backupBeforeChanges = true; + [ObservableProperty] private int _maxCacheGb = 5; + [ObservableProperty] private string _themeVariant = "Dark"; + + public SettingsViewModel(IDialogService dialogService, IGameDetectionService gameDetection) + { + _dialogService = dialogService; + _gameDetection = gameDetection; + } + + public void Load(AppSettings settings) + { + GamePath = settings.GamePath ?? ""; + StoragePath = settings.StoragePath; + NexusApiKey = settings.NexusApiKey ?? ""; + GitHubToken = settings.GitHubToken ?? ""; + AutoCheckUpdates = settings.AutoCheckUpdatesOnStartup; + BackupBeforeChanges = settings.BackupBeforeChanges; + MaxCacheGb = (int)(settings.MaxCacheSizeBytes / (1024 * 1024 * 1024)); + ThemeVariant = settings.ThemeVariant; + Saved = false; + } + + public void Apply(AppSettings settings) + { + settings.GamePath = string.IsNullOrWhiteSpace(GamePath) ? null : GamePath; + settings.StoragePath = StoragePath; + settings.NexusApiKey = string.IsNullOrWhiteSpace(NexusApiKey) ? null : NexusApiKey; + settings.GitHubToken = string.IsNullOrWhiteSpace(GitHubToken) ? null : GitHubToken; + settings.AutoCheckUpdatesOnStartup = AutoCheckUpdates; + settings.BackupBeforeChanges = BackupBeforeChanges; + settings.MaxCacheSizeBytes = (long)MaxCacheGb * 1024 * 1024 * 1024; + settings.ThemeVariant = ThemeVariant; + } + + [RelayCommand] + private async Task BrowseGamePathAsync() + { + var path = await _dialogService.PickFolderAsync("Select Derail Valley folder"); + if (path == null) return; + + if (_gameDetection.ValidateGamePath(path)) + GamePath = path; + else + await _dialogService.ShowMessageAsync("Invalid Path", + "The selected folder does not appear to be a Derail Valley installation."); + } + + [RelayCommand] + private async Task DetectGamePathAsync() + { + var path = _gameDetection.DetectGamePath(); + if (path != null) + GamePath = path; + else + await _dialogService.ShowMessageAsync("Not Found", + "Could not auto-detect Derail Valley. Please select the folder manually."); + } + + [RelayCommand] + private async Task BrowseStoragePathAsync() + { + var path = await _dialogService.PickFolderAsync("Select storage folder"); + if (path != null) StoragePath = path; + } + + [RelayCommand] + private void OpenBackupFolder() + { + var backupDir = Path.Combine(StoragePath, "backups"); + Directory.CreateDirectory(backupDir); + Helpers.PlatformHelper.Open(backupDir); + } + + [RelayCommand] + private void Save(Avalonia.Controls.Window dialog) + { + Saved = true; + dialog.Close(); + } + + [RelayCommand] + private void Cancel(Avalonia.Controls.Window dialog) + { + Saved = false; + dialog.Close(); + } +} diff --git a/DVModManager/ViewModels/ViewModelBase.cs b/DVModManager/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..50c732b --- /dev/null +++ b/DVModManager/ViewModels/ViewModelBase.cs @@ -0,0 +1,5 @@ +using CommunityToolkit.Mvvm.ComponentModel; + +namespace DVModManager.ViewModels; + +public abstract partial class ViewModelBase : ObservableObject; diff --git a/DVModManager/Views/MainWindow.axaml b/DVModManager/Views/MainWindow.axaml new file mode 100644 index 0000000..3baa350 --- /dev/null +++ b/DVModManager/Views/MainWindow.axaml @@ -0,0 +1,226 @@ + + + + + + + + + + + + + + + + + + + + + +