diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..5cc4d85
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,79 @@
+name: Bug Report
+description: Report a reproducible problem in DV Mod Manager
+title: "[Bug]: "
+labels:
+ - bug
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for filing a bug report. Please include clear reproduction steps.
+
+ - type: textarea
+ id: summary
+ attributes:
+ label: Summary
+ description: What is broken?
+ placeholder: A short description of the problem.
+ validations:
+ required: true
+
+ - type: textarea
+ id: steps
+ attributes:
+ label: Steps to Reproduce
+ description: Provide exact steps so we can reproduce the issue.
+ placeholder: |
+ 1. Open DV Mod Manager
+ 2. Click ...
+ 3. Observe ...
+ validations:
+ required: true
+
+ - type: textarea
+ id: expected
+ attributes:
+ label: Expected Behavior
+ placeholder: What did you expect to happen?
+ validations:
+ required: true
+
+ - type: textarea
+ id: actual
+ attributes:
+ label: Actual Behavior
+ placeholder: What happened instead?
+ validations:
+ required: true
+
+ - type: input
+ id: app-version
+ attributes:
+ label: DV Mod Manager Version
+ placeholder: e.g. v1.2.0
+ validations:
+ required: true
+
+ - type: dropdown
+ id: os
+ attributes:
+ label: Operating System
+ options:
+ - Windows
+ - Linux
+ - Other
+ validations:
+ required: true
+
+ - type: textarea
+ id: logs
+ attributes:
+ label: Relevant Logs
+ description: Paste relevant log lines from the app logs directory.
+ render: shell
+
+ - type: textarea
+ id: additional
+ attributes:
+ label: Additional Context
+ description: Screenshots, related mods, or any extra context.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000..e9f8ba7
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,57 @@
+name: Feature Request
+description: Suggest an improvement for DV Mod Manager
+title: "[Feature]: "
+labels:
+ - enhancement
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for the suggestion. Please describe the problem and the expected outcome.
+
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem to Solve
+ description: What pain point are you experiencing?
+ placeholder: Describe the current limitation.
+ validations:
+ required: true
+
+ - type: textarea
+ id: proposal
+ attributes:
+ label: Proposed Solution
+ description: What should DV Mod Manager do?
+ placeholder: Describe your ideal behavior.
+ validations:
+ required: true
+
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Alternatives Considered
+ description: Any other approaches you considered.
+
+ - type: textarea
+ id: user-impact
+ attributes:
+ label: User Impact
+ description: Who benefits and how?
+ placeholder: Example: Helps users manage large mod sets faster.
+
+ - type: textarea
+ id: acceptance
+ attributes:
+ label: Suggested Acceptance Criteria
+ description: Optional checklist for done criteria.
+ placeholder: |
+ - [ ] Behavior A is supported
+ - [ ] UI shows B
+ - [ ] Existing workflow C still works
+
+ - type: textarea
+ id: additional
+ attributes:
+ label: Additional Context
+ description: Mockups, references, or related issues.
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..6b71369
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,27 @@
+name: Build Check
+
+on:
+ push:
+ branches: [main, beta]
+ paths-ignore:
+ - 'DVModProfiles/**'
+ - '**/*.md'
+ pull_request:
+ branches: [main, beta]
+ paths-ignore:
+ - 'DVModProfiles/**'
+ - '**/*.md'
+
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ dotnet-version: 8.0.x
+
+ - name: Build
+ run: dotnet build DVModManager/DVModManager.csproj -c Release
diff --git a/.github/workflows/mod-build.yml b/.github/workflows/mod-build.yml
new file mode 100644
index 0000000..f1e3aca
--- /dev/null
+++ b/.github/workflows/mod-build.yml
@@ -0,0 +1,31 @@
+name: CI
+
+on:
+ push:
+ branches: [main, beta]
+ pull_request:
+ branches: [main, beta]
+
+jobs:
+ build:
+ runs-on: [self-hosted, Windows]
+
+ defaults:
+ run:
+ working-directory: DVModProfiles
+
+ env:
+ ReferencePath: 'C:\References\Derail Valley\DerailValley_Data\Managed'
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Restore
+ run: dotnet restore DVModProfiles.csproj
+
+ - name: Lint
+ run: dotnet format DVModProfiles.csproj --verify-no-changes --no-restore
+
+ - name: Build
+ run: dotnet build DVModProfiles.csproj -c Release --no-restore
diff --git a/.github/workflows/mod-release.yml b/.github/workflows/mod-release.yml
new file mode 100644
index 0000000..65c8589
--- /dev/null
+++ b/.github/workflows/mod-release.yml
@@ -0,0 +1,68 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - 'dvmodprofiles-v*'
+
+permissions:
+ contents: write
+
+jobs:
+ release:
+ runs-on: [self-hosted, Windows]
+
+ defaults:
+ run:
+ working-directory: DVModProfiles
+
+ env:
+ ReferencePath: 'C:\References\Derail Valley\DerailValley_Data\Managed'
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v6
+
+ - name: Verify tag matches info.json version
+ shell: powershell
+ run: |
+ $ErrorActionPreference = 'Stop'
+ # The tag carries the mod prefix; the version fields do not.
+ $tag = '${{ github.ref_name }}'
+ $version = $tag -replace '^dvmodprofiles-v', ''
+ $info = Get-Content -Raw info.json | ConvertFrom-Json
+ $errors = @()
+
+ if ($info.Version -ne $version) {
+ $errors += "info.json Version is '$($info.Version)', expected '$version'."
+ }
+
+ $release = (Get-Content -Raw repository.json | ConvertFrom-Json).Releases |
+ Where-Object { $_.Id -eq $info.Id }
+ if (-not $release) {
+ $errors += "repository.json has no Releases entry with Id '$($info.Id)'."
+ } else {
+ if ($release.Version -ne $version) {
+ $errors += "repository.json Version is '$($release.Version)', expected '$version'."
+ }
+ # The download path segment is the full tag, not the bare version.
+ $expectedUrl = "https://github.com/${{ github.repository }}/releases/download/$tag/$($info.Id).zip"
+ if ($release.DownloadUrl -ne $expectedUrl) {
+ $errors += "repository.json DownloadUrl is '$($release.DownloadUrl)', expected '$expectedUrl'."
+ }
+ }
+
+ if ($errors.Count -gt 0) {
+ $errors | ForEach-Object { Write-Host "::error::$_" }
+ Write-Error "Version mismatch for tag '$tag'. Bump every version field in info.json and repository.json (including the version inside DownloadUrl) before tagging."
+ exit 1
+ }
+ Write-Host "Releasing version $version"
+
+ - name: Build and package
+ run: dotnet build DVModProfiles.csproj -c Release
+
+ - name: Create GitHub release
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: gh release create "${{ github.ref_name }}" "dist/DVModProfiles.zip" --title "${{ github.ref_name }}" --generate-notes
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..553a4bd
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,59 @@
+name: Release
+
+on:
+ push:
+ tags: ['dvmodmanager-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
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ gh release create "${{ github.ref_name }}" artifacts/*/*.zip \
+ --repo "${{ github.repository }}" \
+ --title "${{ github.ref_name }}" \
+ --generate-notes \
+ --draft="$IS_BETA" \
+ --prerelease="$IS_BETA"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5d47c7c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,34 @@
+## Build results
+[Bb]in/
+[Oo]bj/
+publish/
+
+## DVModProfiles compilation artifacts
+DVModProfiles/build/
+DVModProfiles/dist/
+Directory.Build.targets
+
+## 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..fd9b565
--- /dev/null
+++ b/DVModManager/App.axaml.cs
@@ -0,0 +1,142 @@
+using Avalonia;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using DVModManager.Models;
+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();
+
+ // Initialize localization service with default language (English).
+ // If settings are loaded later, the language will be updated via MainWindowViewModel.
+ // For now, we just ensure the service is ready before any UI is created.
+ var localizationService = Services.GetRequiredService();
+ // Expose as an Application-level resource so XAML can bind to it via {StaticResource Loc}
+ Resources["Loc"] = localizationService;
+ // Language will be applied after settings load in MainWindowViewModel.InitializeAsync()
+
+ // 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 string GetAppDataDirectory()
+ {
+ if (OperatingSystem.IsLinux())
+ {
+ // Respect XDG_CONFIG_HOME if set to a valid absolute path, otherwise fall back to ~/.config
+ var xdg = Environment.GetEnvironmentVariable("XDG_CONFIG_HOME");
+ var configBase = !string.IsNullOrEmpty(xdg) && Path.IsPathRooted(xdg)
+ ? xdg
+ : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config");
+ return Path.Combine(configBase, ManagerStorage.DirectoryName);
+ }
+ return Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ ManagerStorage.DirectoryName);
+ }
+
+ private static void ConfigureServices(IServiceCollection services)
+ {
+ // Logging
+ var logDir = Path.Combine(GetAppDataDirectory(), "logs");
+ services.AddLogging(b =>
+ {
+ b.AddConsole();
+ b.AddProvider(new FileLoggerProvider(logDir, LogLevel.Debug));
+ b.SetMinimumLevel(LogLevel.Debug);
+ });
+
+ // HTTP
+ services.AddHttpClient();
+
+ // Localization (must be registered early, before UI/ViewModels)
+ services.AddSingleton();
+
+ // 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/LocalizeExtension.cs b/DVModManager/Converters/LocalizeExtension.cs
new file mode 100644
index 0000000..2040c3a
--- /dev/null
+++ b/DVModManager/Converters/LocalizeExtension.cs
@@ -0,0 +1,73 @@
+using System.ComponentModel;
+using Avalonia.Data;
+using Avalonia.Markup.Xaml;
+using Avalonia.Markup.Xaml.MarkupExtensions;
+using DVModManager.Services;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace DVModManager.Converters;
+
+///
+/// Markup extension for localizing text.
+/// Usage in XAML: Text="{conv:Localize toolbar.save_profile}"
+///
+public class LocalizeExtension : MarkupExtension
+{
+ public string Key { get; set; } = "";
+
+ public LocalizeExtension() { }
+
+ public LocalizeExtension(string key)
+ {
+ Key = key;
+ }
+
+ public override object ProvideValue(IServiceProvider serviceProvider)
+ {
+ if (string.IsNullOrWhiteSpace(Key))
+ return string.Empty;
+
+ var source = App.Services.GetRequiredService();
+
+ // Wrap each key in a dedicated observable so PropertyChanged("Value") fires
+ // on language change — Avalonia's ReflectionBindingExtension reliably handles
+ // named-property change notifications but NOT "Item[]" (WPF/Silverlight convention).
+ var localizedValue = new LocalizedValue(source, Key);
+
+ var binding = new ReflectionBindingExtension(nameof(LocalizedValue.Value))
+ {
+ Source = localizedValue,
+ Mode = BindingMode.OneWay,
+ FallbackValue = Key,
+ };
+
+ return binding.ProvideValue(serviceProvider);
+ }
+}
+
+///
+/// Single-key observable wrapper over .
+/// Raises PropertyChanged("Value") whenever the active language changes,
+/// ensuring bound UI elements update immediately without relying on "Item[]".
+///
+internal sealed class LocalizedValue : INotifyPropertyChanged
+{
+ private readonly ILocalizationService _service;
+ private readonly string _key;
+
+ public string Value => _service[_key];
+
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public LocalizedValue(ILocalizationService service, string key)
+ {
+ _service = service;
+ _key = key;
+ service.LanguageChanged += OnLanguageChanged;
+ }
+
+ private void OnLanguageChanged(object? sender, EventArgs e)
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(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/Converters/StringKeyConverter.cs b/DVModManager/Converters/StringKeyConverter.cs
new file mode 100644
index 0000000..95c65fe
--- /dev/null
+++ b/DVModManager/Converters/StringKeyConverter.cs
@@ -0,0 +1,40 @@
+using Avalonia.Data.Converters;
+using DVModManager.Services;
+
+namespace DVModManager.Converters;
+
+///
+/// Value converter that translates a localization key (from ViewModel) to a localized string.
+/// Binding: Text="{Binding Some ObservableProperty, Converter={StaticResource LocalizeConverter}}"
+/// where the property returns a localization key like "status.activated".
+///
+public class StringKeyConverter : IValueConverter
+{
+ public object? Convert(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
+ {
+ if (value is not string key || string.IsNullOrEmpty(key))
+ return "";
+
+ try
+ {
+ var localizationService = App.Services.GetService(typeof(ILocalizationService)) as ILocalizationService;
+ if (localizationService == null)
+ return key; // Fallback
+
+ // If parameter contains format args, apply them
+ if (parameter is object[] args)
+ return localizationService.GetString(key, args);
+
+ return localizationService.GetString(key);
+ }
+ catch
+ {
+ return key;
+ }
+ }
+
+ public object? ConvertBack(object? value, Type targetType, object? parameter, System.Globalization.CultureInfo culture)
+ {
+ throw new NotImplementedException();
+ }
+}
diff --git a/DVModManager/DVModManager.csproj b/DVModManager/DVModManager.csproj
new file mode 100644
index 0000000..6808c94
--- /dev/null
+++ b/DVModManager/DVModManager.csproj
@@ -0,0 +1,42 @@
+
+
+
+ WinExe
+ net8.0
+ DVModManager
+ DVModManager
+ 0.0.4
+ enable
+ enable
+ latest
+
+ true
+ true
+ true
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/DVModManager/Helpers/InfoJsonLocator.cs b/DVModManager/Helpers/InfoJsonLocator.cs
new file mode 100644
index 0000000..6d2f360
--- /dev/null
+++ b/DVModManager/Helpers/InfoJsonLocator.cs
@@ -0,0 +1,37 @@
+namespace DVModManager.Helpers;
+
+///
+/// Locates a mod's Info.json on disk regardless of filename casing.
+/// Windows installs are case-insensitive, but on Linux many mod archives
+/// ship the file as "info.json" or "Info.JSON", which the exact-case lookup
+/// previously missed — stripping version/repo metadata from scans and profiles.
+///
+public static class InfoJsonLocator
+{
+ ///
+ /// Returns the full path of the mod metadata file in ,
+ /// preferring the exact "Info.json" casing and otherwise matching
+ /// "info.json" case-insensitively. Returns null if none is found.
+ ///
+ public static string? Locate(string folder)
+ {
+ if (string.IsNullOrEmpty(folder) || !Directory.Exists(folder))
+ return null;
+
+ var exact = Path.Combine(folder, "Info.json");
+ if (File.Exists(exact)) return exact;
+
+ // Case-insensitive fallback for Linux (info.json, Info.JSON, ...).
+ try
+ {
+ var match = Directory.EnumerateFiles(folder)
+ .FirstOrDefault(f =>
+ string.Equals(Path.GetFileName(f), "Info.json", StringComparison.OrdinalIgnoreCase));
+ return match;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+}
\ No newline at end of file
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..bf499df
--- /dev/null
+++ b/DVModManager/Models/AppSettings.cs
@@ -0,0 +1,41 @@
+namespace DVModManager.Models;
+
+public class AppSettings : PublicSettings
+{
+ public string? GamePath { get; set; }
+
+ public string? NexusApiKey { get; set; }
+ public string? GitHubToken { get; set; }
+
+ /// "Dark" or "Light"
+ public string ThemeVariant { get; set; } = "Dark";
+
+ /// Language code: "en", "de", "fr", etc.
+ public string Language { get; set; } = "en";
+
+ public bool AutoCheckUpdatesOnStartup { get; set; } = true;
+
+ public bool BackupBeforeChanges { get; set; } = true;
+
+ public bool EnableVersionArchiving { get; set; } = true;
+
+ /// Maximum combined size of all version archives before pruning. Default 5 GB.
+ public long MaxCacheSizeBytes { get; set; } = 5L * 1024 * 1024 * 1024;
+
+ /// User-defined visual groups for both mod panels.
+ public List ModGroups { get; set; } = [];
+
+ /// Group IDs that are currently collapsed in the Available panel (persisted).
+ public HashSet CollapsedGroupIdsAvailable { get; set; } = [];
+
+ /// Group IDs that are currently collapsed in the Active panel (persisted).
+ public HashSet CollapsedGroupIdsActive { get; set; } = [];
+
+ /// Legacy field — migrated to per-panel sets on load.
+ public HashSet? CollapsedGroupIds { get; set; }
+
+ // --- Derived paths ---
+ 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/ManagerStorage.cs b/DVModManager/Models/ManagerStorage.cs
new file mode 100644
index 0000000..2fc25cc
--- /dev/null
+++ b/DVModManager/Models/ManagerStorage.cs
@@ -0,0 +1,34 @@
+namespace DVModManager.Models;
+
+///
+/// Where the manager keeps what it persists, and how it names those files.
+///
+///
+/// This file is compiled into the DVModProfiles mod as well as the manager, so
+/// anything added here has to stay compilable under net48.
+///
+public static class ManagerStorage
+{
+ public const string DirectoryName = "DVModManager";
+
+ public const string SettingsFileName = "settings.json";
+
+ public const string ProfilesDirectoryName = "profiles";
+
+ /// The profile the manager offers whether or not a file exists for it.
+ public const string DefaultProfileName = "Default";
+
+ ///
+ /// Holds . It does not move with
+ /// , which is why anything looking for the
+ /// manager's data has to start here and follow that path afterwards.
+ ///
+ public static string ConfigDirectory =>
+ Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), DirectoryName);
+
+ public static string SettingsFilePath => Path.Combine(ConfigDirectory, SettingsFileName);
+
+ /// The file a profile of this name is saved to, within a profiles directory.
+ public static string ProfileFileName(string profileName) =>
+ string.Concat(profileName.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c)) + ".json";
+}
diff --git a/DVModManager/Models/ModGroup.cs b/DVModManager/Models/ModGroup.cs
new file mode 100644
index 0000000..6c6fa35
--- /dev/null
+++ b/DVModManager/Models/ModGroup.cs
@@ -0,0 +1,11 @@
+namespace DVModManager.Models;
+
+public class ModGroup
+{
+ public string Id { get; set; } = Guid.NewGuid().ToString();
+ public string Name { get; set; } = "New Group";
+ /// Which panel this group belongs to: "available", "active", or null for both (legacy).
+ public string? Panel { get; set; }
+ /// Ordered list of mod IDs that belong to this group.
+ public List ModIds { get; set; } = [];
+}
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..5955bf5
--- /dev/null
+++ b/DVModManager/Models/ModProfile.cs
@@ -0,0 +1,27 @@
+namespace DVModManager.Models;
+
+///
+/// A profile as it is stored on disk.
+///
+///
+/// This file is compiled into the DVModProfiles mod as well as the manager, so
+/// anything added here has to stay compilable under net48.
+///
+public class ModProfile
+{
+ public string Name { get; set; } = ManagerStorage.DefaultProfileName;
+ 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; }
+ /// GitHub repository URL (e.g. https://github.com/owner/repo). Used to auto-download on import.
+ public string? RepositoryUrl { get; set; }
+ /// Nexus Mods or other homepage URL. Opened in the browser when auto-download is not possible.
+ public string? HomePageUrl { get; set; }
+}
diff --git a/DVModManager/Models/ModState.cs b/DVModManager/Models/ModState.cs
new file mode 100644
index 0000000..f5c53d7
--- /dev/null
+++ b/DVModManager/Models/ModState.cs
@@ -0,0 +1,11 @@
+namespace DVModManager.Models;
+
+public enum ModState
+{
+ Active,
+ Inactive,
+ UpdateAvailable,
+ MissingDependency,
+ NoMetadata,
+ Missing // installed in a group but no longer found on disk
+}
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/Models/PublicSettings.cs b/DVModManager/Models/PublicSettings.cs
new file mode 100644
index 0000000..60fe7f8
--- /dev/null
+++ b/DVModManager/Models/PublicSettings.cs
@@ -0,0 +1,22 @@
+namespace DVModManager.Models;
+
+///
+/// The part of that anything outside the manager
+/// depends on. Settings that shouldn't be exposed externally such as API keys should be
+/// on .
+///
+///
+/// This file is compiled into the DVModProfiles mod as well as the manager, so
+/// anything added here has to stay compilable under net48.
+///
+public class PublicSettings
+{
+ /// Root storage path for downloads, version archives, logs, profiles.
+ public string StoragePath { get; set; } = ManagerStorage.ConfigDirectory;
+
+ /// The profile the manager last applied to the game's mod folder.
+ public string ActiveProfileName { get; set; } = ManagerStorage.DefaultProfileName;
+
+ /// The path where profile data is written.
+ public string ProfilesPath => Path.Combine(StoragePath, ManagerStorage.ProfilesDirectoryName);
+}
diff --git a/DVModManager/Program.cs b/DVModManager/Program.cs
new file mode 100644
index 0000000..70a0686
--- /dev/null
+++ b/DVModManager/Program.cs
@@ -0,0 +1,15 @@
+using Avalonia;
+using DVModManager;
+
+// OLE drag & drop on Windows requires an STA thread.
+if (OperatingSystem.IsWindows())
+{
+ Thread.CurrentThread.SetApartmentState(ApartmentState.Unknown);
+ Thread.CurrentThread.SetApartmentState(ApartmentState.STA);
+}
+
+AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace()
+ .StartWithClassicDesktopLifetime(args);
diff --git a/DVModManager/Resources/strings.csv b/DVModManager/Resources/strings.csv
new file mode 100644
index 0000000..e955123
--- /dev/null
+++ b/DVModManager/Resources/strings.csv
@@ -0,0 +1,229 @@
+key,en,de,fr,zh
+lang.en,English,Englisch,Anglais,
+lang.de,German,Deutsch,Allemand,
+lang.fr,French,Français,Français,
+lang.zh,Chinese,Chinesisch,Chinois,
+window.title,DV ModManager,DV ModManager,DV ModManager,
+toolbar.profile.label,Profile:,Profil:,Profil :,
+toolbar.save_profile,Save,Speichern,Enregistrer,
+toolbar.save_profile.tooltip,Save current mod selection as the active profile,Aktuelle Modauswahl als aktives Profil speichern,Enregistrer la sélection actuelle des mods comme profil actif,
+toolbar.manage_profiles,Manage Profiles,Profile verwalten,Gérer les profils,
+toolbar.manage_profiles.tooltip,"Load, import, export or delete profiles","Profile laden, importieren, exportieren oder löschen","Charger, importer, exporter ou supprimer des profils",
+toolbar.refresh,⟳ Refresh,⟳ Aktualisieren,⟳ Actualiser,
+toolbar.refresh.tooltip,Rescan the Mods folder,Mods-Ordner neu scannen,Rescanner le dossier Mods,
+toolbar.unload_all,⏊ Unload All,⏊ Alle entladen,⏊ Décharger tout,
+toolbar.unload_all.tooltip,Deactivate all currently active mods,Alle derzeit aktiven Mods deaktivieren,Désactiver tous les mods actuellement actifs,
+toolbar.check_updates,↑ Check Updates,↑ Updates prüfen,↑ Vérifier les mises à jour,
+toolbar.check_updates.tooltip,Check all mods for available updates,Alle Mods auf verfügbare Updates prüfen,Vérifier les mises à jour disponibles pour tous les mods,
+toolbar.update_all,↑↑ Update All,↑↑ Alle aktualisieren,↑↑ Mettre à jour tout,
+toolbar.update_all.tooltip,Download and install all available updates,Alle verfügbaren Updates herunterladen und installieren,Télécharger et installer toutes les mises à jour disponibles,
+toolbar.settings,⚙,⚙,⚙,
+toolbar.settings.tooltip,Open settings,Einstellungen öffnen,Ouvrir les paramètres,
+panel.available,Available Mods,Verfügbare Mods,Mods disponibles,
+panel.active,Active Mods,Aktive Mods,Mods actifs,
+panel.details,Details,Details,Détails,
+activate.button,▶,▶,▶,
+activate.button.tooltip,Activate selected mod,Ausgewählten Mod aktivieren,Activer le mod sélectionné,
+deactivate.button,◀,◀,◀,
+deactivate.button.tooltip,Deactivate selected mod,Ausgewählten Mod deaktivieren,Désactiver le mod sélectionné,
+install.button,⊕,⊕,⊕,
+install.button.tooltip,Install mod or import modpack,Mod installieren oder Modpack importieren,Installer un mod ou importer un modpack,
+statusbar.game_running,Game Running,Spiel läuft,Jeu en cours d'exécution,
+statusbar.game_not_running,Game Not Running,Spiel läuft nicht,Jeu non en cours d'exécution,
+statusbar.ready,Ready.,Bereit.,Prêt.,
+settings.title,Settings,Einstellungen,Paramètres,
+settings.section.game_path,Derail Valley Installation,Derail Valley-Installation,Installation de Derail Valley,
+settings.game_path.placeholder,Path to Derail Valley folder...,Pfad zum Derail Valley-Ordner...,Chemin d'accès au dossier Derail Valley...,
+settings.game_path.browse,Browse,Durchsuchen,Parcourir,
+settings.game_path.auto,Auto,Automatisch,Automatique,
+settings.game_path.auto.tooltip,Auto-detect via Steam,Per Steam automatisch erkennen,Détection automatique via Steam,
+settings.section.storage,Storage Folder,Speicherordner,Dossier de stockage,
+settings.storage.description,"Where version archives, profiles and logs are kept.","Hier werden Versionsarchive, Profile und Protokolle gespeichert.","Où les archives de version, les profils et les journaux sont conservés.",
+settings.storage.browse,Browse,Durchsuchen,Parcourir,
+settings.section.nexus,Nexus Mods API Key,Nexus Mods API-Schlüssel,Clé API Nexus Mods,
+settings.nexus.description,Required for Nexus Mods update checks. Leave blank to skip.,Erforderlich für Nexus Mods-Update-Überprüfungen. Leer lassen zum Überspringen.,Requis pour les vérifications de mise à jour de Nexus Mods. Laisser en blanc pour ignorer.,
+settings.nexus.placeholder,Your Nexus Mods API key...,Ihr Nexus Mods API-Schlüssel...,Votre clé API Nexus Mods...,
+settings.section.github,GitHub Token (optional),GitHub-Token (optional),Jeton GitHub (facultatif),
+settings.github.description,Increases the GitHub API rate limit from 60 to 5000 requests/hour.,Erhöht das GitHub API-Ratelimit von 60 auf 5000 Anfragen pro Stunde.,Augmente la limite de débit de l'API GitHub de 60 à 5000 requêtes par heure.,
+settings.github.placeholder,ghp_...,ghp_...,ghp_...,
+settings.section.general,General,Allgemein,Général,
+settings.auto_check_updates,Check for updates on startup,Bei Start auf Updates prüfen,Vérifier les mises à jour au démarrage,
+settings.backup_before_changes,Backup mods before bulk changes,Mods vor Massenänderungen sichern,Sauvegarder les mods avant les modifications en bloc,
+settings.enable_version_archiving,Archive versions for rollback,Versionen für Rollback archivieren,Archiver les versions pour restauration,
+settings.backup_folder_button,Open Folder,Ordner öffnen,Ouvrir le dossier,
+settings.max_cache_size,Max version cache size (GB),Max. Versionscachegröße (GB),Taille maximale du cache de version (Go),
+settings.language,Language,Sprache,Langue,
+settings.language.english,English,Englisch,Anglais,
+settings.button.cancel,Cancel,Abbrechen,Annuler,
+settings.button.save,Save,Speichern,Enregistrer,
+dialog.button.ok,OK,OK,OK,
+dialog.button.yes,Yes,Ja,Oui,
+dialog.button.no,No,Nein,Non,
+dialog.uninstall_title,Uninstall Mod,Mod deinstallieren,Désinstaller le mod,
+dialog.uninstall_message,"Remove ''{0}''? The current version will be archived for rollback, then deleted.","„{0}"" entfernen? Die aktuelle Version wird zur Wiederherstellung archiviert und dann gelöscht.","Supprimer « {0} » ? La version actuelle sera archivée pour restauration, puis supprimée.",
+dialog.update_all_title,Update All,Alle aktualisieren,Mettre à jour tout,
+dialog.update_all_message,Update {0} mod(s)? Current versions will be archived.,{0} Mod(s) aktualisieren? Aktuelle Versionen werden archiviert.,Mettre à jour {0} mod(s) ? Les versions actuelles seront archivées.,
+dialog.unload_all_title,Unload All Mods,Alle Mods entfernen,Décharger tous les mods,
+dialog.unload_all_message,Deactivate all {0} active mod(s)?,Alle {0} aktiven Mod(s) deaktivieren?,Désactiver tous les {0} mod(s) actif(s) ?,
+dialog.download_missing_title,Download Missing Mods,Fehlende Mods herunterladen,Télécharger les mods manquants,
+dialog.invalid_path_title,Invalid Path,Ungültiger Pfad,Chemin invalide,
+dialog.invalid_path_message,The selected folder does not appear to be a Derail Valley installation.,Der ausgewählte Ordner scheint keine Derail Valley-Installation zu sein.,Le dossier sélectionné ne semble pas être une installation de Derail Valley.,
+dialog.game_not_found_title,Not Found,Nicht gefunden,Non trouvé,
+dialog.game_not_found_message,Could not auto-detect Derail Valley. Please select the folder manually.,Derail Valley konnte nicht automatisch erkannt werden. Wählen Sie den Ordner manuell aus.,Impossible de détecter automatiquement Derail Valley. Veuillez sélectionner le dossier manuellement.,
+modstate.active,Active,Aktiv,Actif,
+modstate.inactive,Inactive,Inaktiv,Inactif,
+modstate.update_available,Update available → {0},Update verfügbar → {0},Mise à jour disponible → {0},
+modstate.missing_dependency,Missing dependency,Fehlende Abhängigkeit,Dépendance manquante,
+modstate.no_metadata,No metadata,Keine Metadaten,Pas de métadonnées,
+modstate.missing,Missing — not found on disk,Fehlend – nicht auf Datenträger gefunden,Manquant — non trouvé sur le disque,
+status.detected_game,Detected game at: {0},Spiel erkannt unter: {0},Jeu détecté à : {0},
+status.game_not_found,Game not found. Please set the game path in Settings.,Spiel nicht gefunden. Bitte legen Sie den Spielpfad in den Einstellungen fest.,Jeu non trouvé. Veuillez définir le chemin du jeu dans les paramètres.,
+status.startup_error,Startup error: {0},Startfehler: {0},Erreur de démarrage : {0},
+status.no_mods_found,No mods found in {0},Keine Mods gefunden in {0},Aucun mod trouvé dans {0},
+status.mods_found,"Found {0} mod(s) — {1} active, {2} inactive.","{0} Mod(s) gefunden – {1} aktiv, {2} inaktiv.","{0} mod(s) trouvé(s) — {1} actif, {2} inactif.",
+status.game_path_not_set,Game path not set. Open Settings to configure.,Spielpfad nicht gesetzt. Öffnen Sie Einstellungen zum Konfigurieren.,Chemin du jeu non défini. Ouvrez Paramètres pour configurer.,
+status.mods_folder_not_found,Mods folder not found: {0},Mods-Ordner nicht gefunden: {0},Dossier Mods non trouvé : {0},
+status.scan_error,Scan error: {0},Scan-Fehler: {0},Erreur de numérisation : {0},
+status.no_active_mods,No active mods.,Keine aktiven Mods.,Aucun mod actif.,
+status.resolving_dependencies,Resolving dependencies for {0}...,Löse Abhängigkeiten für {0} auf…,Résolution des dépendances pour {0}…,
+status.missing_dependencies,Missing dependencies for {0}: {1},Fehlende Abhängigkeiten für {0}: {1},Dépendances manquantes pour {0} : {1},
+status.activated,Activated: {0},Aktiviert: {0},Activé : {0},
+status.activation_failed,Failed to activate: {0},Aktivierung fehlgeschlagen: {0},Impossible d'activer : {0},
+status.activated_count,Activated {0}/{1} mod(s) in group ''{2}''.,"{0}/{1} Mod(s) in Gruppe „{2}"" aktiviert.",{0}/{1} mod(s) activé(s) dans le groupe « {2} ».,
+status.activated_with_missing_deps,Activated {0}/{1} — missing deps: {2},Aktiviert {0}/{1} – fehlende Abhängigkeiten: {2},Activé {0}/{1} — dépendances manquantes : {2},
+status.deactivated,Deactivated: {0},Deaktiviert: {0},Désactivé : {0},
+status.deactivation_failed,Failed to deactivate: {0},Deaktivierung fehlgeschlagen: {0},Impossible de désactiver : {0},
+status.deactivated_count,Deactivated {0}/{1} mod(s) in group ''{2}''.,"{0}/{1} Mod(s) in Gruppe „{2}"" deaktiviert.",{0}/{1} mod(s) désactivé(s) dans le groupe « {2} ».,
+status.install_success,Installed: {0},Installiert: {0},Installé : {0},
+status.install_failed,"Install failed. Ensure the archive contains Info.json.","Installation fehlgeschlagen. Stellen Sie sicher, dass das Archiv Info.json enthält.","Échec de l'installation. Assurez-vous que l'archive contient Info.json.",
+status.install_multi_result,"Installed {0}/{1} mod(s) ({2} failed).","{0}/{1} Mod(s) installiert ({2} fehlgeschlagen).","{0}/{1} mod(s) installé(s) ({2} échoué(s)).",
+status.uninstalled,Uninstalled: {0},Deinstalliert: {0},Désinstallé : {0},
+status.checking_updates,Checking for updates...,Auf Updates wird überprüft...,Vérification des mises à jour…,
+status.updates_available,{0} update(s) available.,{0} Update(s) verfügbar.,{0} mise(s) à jour disponible(s).,
+status.all_up_to_date,All mods are up to date.,Alle Mods sind aktuell.,Tous les mods sont à jour.,
+status.update_opened,Opened mod page for {0} v{1},Modseite für {0} v{1} geöffnet,Page de mod ouverte pour {0} v{1},
+status.updating,Updating {0} to v{1}...,,Mise à jour de {0} vers v{1}…,
+status.updated,Updated {0} to v{1},{0} auf v{1} aktualisiert,{0} mis à jour vers v{1},
+status.update_failed,Update failed for {0},Update fehlgeschlagen für {0},Échec de la mise à jour de {0},
+status.rolling_back,Rolling back {0} to v{1}...,Rollback von {0} zu v{1}…,Restauration de {0} à v{1}…,
+status.rolled_back,Rolled back {0} to v{1},Rollback von {0} zu v{1} durchgeführt,{0} restauré à v{1},
+status.rollback_failed,Rollback failed for {0},Rollback fehlgeschlagen für {0},Échec du rollback pour {0},
+status.profile_saved,Profile ''{0}'' saved.,"Profil „{0}"" gespeichert.",Profil « {0} » enregistré.,
+status.profile_applied,Applied profile ''{0}''.,"Profil „{0}"" angewendet.",Profil « {0} » appliqué.,
+status.profile_already_applied,Profile is already applied.,Profil ist bereits angewendet.,Le profil est déjà appliqué.,
+status.no_downloadable_updates,No downloadable updates available.,Keine herunterladbaren Updates verfügbar.,Aucune mise à jour téléchargeable disponible.,
+status.downloading_mods,Downloading {0} mod(s)...,{0} Mod(s) werden heruntergeladen…,Téléchargement de {0} mod(s)…,
+status.download_complete,Downloaded {0} mod(s). Nexus mods ({1}) opened in browser — install manually then re-apply.,{0} Mod(s) heruntergeladen. Nexus-Mods ({1}) im Browser geöffnet – manuell installieren und dann erneut anwenden.,{0} mod(s) téléchargé(s). Mods Nexus ({1}) ouverts dans le navigateur — installer manuellement puis appliquer à nouveau.,
+status.activating_mod,Activating {0}...,Aktiviere {0}…,Activation de {0}…,
+status.deactivating_mod,Deactivating {0}...,Deaktiviere {0}…,Désactivation de {0}…,
+status.activating_group,Activating {0} mod(s)...,Aktiviere {0} Mod(s)…,Activation de {0} mod(s)…,
+status.deactivating_group,Deactivating {0} mod(s)...,Deaktiviere {0} Mod(s)…,Désactivation de {0} mod(s)…,
+busy.installing_mod,Installing mod...,Mod wird installiert…,Installation du mod…,
+busy.creating_backup,Creating backup...,Erstelle Backup…,Création d'une sauvegarde…,
+busy.resolving_mod,Resolving {0}...,Löse {0} auf…,Résolution de {0}…,
+busy.downloading_mod,Downloading {0}...,Lade {0} herunter…,Téléchargement de {0}…,
+busy.download_progress,Downloading {0} {1:P0}...,Lade {0} {1:P0} herunter…,Téléchargement de {0} {1:P0}…,
+busy.downloading_multiple,Downloading {0} ({1}/{2})...,Lade {0} herunter ({1}/{2})…,Téléchargement de {0} ({1}/{2})…,
+busy.updating_mod,Updating {0}...,Aktualisiere {0}…,Mise à jour de {0}…,
+busy.update_progress,Updating {0}… {1:P0},Aktualisiere {0}… {1:P0},Mise à jour de {0}… {1:P0},
+busy.update_multiple,Updating {0}… {1:P0} ({2}/{3})...,Aktualisiere {0}… {1:P0} ({2}/{3})…,Mise à jour de {0}… {1:P0} ({2}/{3})…,
+menu.operations,Operations,Operationen,Opérations,
+menu.version_history,Version History,Versionsverlauf,Historique des versions,
+filter.search_mods,Search mods...,Mods durchsuchen...,Rechercher des mods...,
+ui.select_mod_details,Select a mod to see details,Wählen Sie ein Mod aus um Details zu sehen,Sélectionnez un mod pour voir les détails,
+button.add_group,+ Group,+ Gruppe,+ Groupe,
+button.rename_group,Rename group,Gruppe umbenennen,Renommer le groupe,
+button.delete_group,Delete group,Gruppe löschen,Supprimer le groupe,
+badge.update,UPDATE,AKTUALISIERUNG,MISE À JOUR,
+link.homepage,🌐 HomePage,🌐 Startseite,🌐 Page d'accueil,
+link.repository,📦 Repository,📦 Repository,📦 Référentiel,
+mod.missing_dependencies,Missing Dependencies,Fehlende Abhängigkeiten,Dépendances manquantes,
+button.uninstall,✕ Uninstall,✕ Deinstallieren,✕ Désinstaller,
+button.update,↑ Update,↑ Aktualisieren,↑ Mettre à jour,
+menu.version_history,Version History,Versionsverlauf,Historique des versions,
+button.rollback,↩ Rollback to Selected Version,↩ Zu ausgewählter Version zurücksetzen,↩ Revenir à la version sélectionnée,
+profile.dialog_title,Manage Profiles,Profile verwalten,Gérer les profils,
+profile.saved_profiles,Saved Profiles,Gespeicherte Profile,Profils enregistrés,
+profile.help_text,Use the main toolbar to save the current mod selection as a new profile.,Verwenden Sie die Hauptsymbolleiste um die aktuelle Mod-Auswahl als neues Profil zu speichern.,Utilisez la barre d'outils principale pour enregistrer la sélection actuelle des mods comme nouveau profil.,
+profile.button_apply,✓ Apply,✓ Anwenden,✓ Appliquer,
+profile.button_export,Export,Exportieren,Exporter,
+profile.button_import,Import,Importieren,Importer,
+profile.button_delete,✕ Delete,✕ Löschen,✕ Supprimer,
+button.close,Close,Schließen,Fermer,
+modversion.source.cached,Cached,Zwischengespeichert,Mis en cache,
+modversion.source.github,GitHub,GitHub,GitHub,
+modversion.source.nexus,Nexus,Nexus,Nexus,
+modversion.source.manual,Manual,Manuell,Manuel,
+export.choose_format.title,Export Profile,Profil exportieren,Exporter le profil,
+export.button.json,Export as JSON,Als JSON exportieren,Exporter en JSON,
+export.button.zip,Export as ZIP,Als ZIP exportieren,Exporter en ZIP,
+export.zip_warning.title,Redistribution Warning,Weitergabe-Warnung,Avertissement de redistribution,
+export.zip_warning.message,"Most mods do not allow redistribution on other sites. This tool takes no responsibility for unauthorized sharing.","Die meisten Mods erlauben keine Weitergabe auf anderen Seiten. Dieses Tool übernimmt keine Verantwortung für unerlaubtes Teilen.","La plupart des mods n'autorisent pas la redistribution sur d'autres sites. Cet outil décline toute responsabilité en cas de partage non autorisé.",
+import.modpack.title,Import Modpack,Modpack importieren,Importer un modpack,
+import.modpack.preamble,The following mods are included in this modpack:,Die folgenden Mods sind in diesem Modpack enthalten:,Les mods suivants sont inclus dans ce modpack :,
+import.modpack.security_warning,"⚠ Mods not downloaded from their original source may have been modified and could be harmful. This tool takes no responsibility.","⚠ Mods die nicht von ihrer ursprünglichen Quelle stammen können modifiziert und schädlich sein. Dieses Tool übernimmt keine Verantwortung.","⚠ Les mods non téléchargés depuis leur source d'origine peuvent avoir été modifiés et être dangereux. Cet outil décline toute responsabilité.",
+import.button.confirm,Import,Importieren,Importer,
+status.modpack_imported,Modpack ''{0}'' imported.,Modpack „{0}" importiert.,Modpack « {0} » importé.,
+status.modpack_import_failed,Modpack import failed.,Modpack-Import fehlgeschlagen.,Échec de l'importation du modpack.,
+dialog.redownload_title,Apply Correct Versions,Richtige Versionen anwenden,Appliquer les versions correctes,
+dialog.redownload_will_rollback,"Will rollback from cached archive ({0}):",Wird aus Cache-Archiv zurückgesetzt ({0}):,Sera restauré depuis le cache ({0}) :,
+dialog.redownload_will_download,"Will download from repository ({0}):",Wird vom Repository heruntergeladen ({0}):,Sera téléchargé depuis le référentiel ({0}) :,
+dialog.redownload_entry," • {0} → v{1}"," • {0} → v{1}"," • {0} → v{1}",
+dialog.redownload_proceed,Proceed?,Fortfahren?,Continuer ?,
+dialog.redownload_failed_title,Version Fix Failed,Version-Korrektur fehlgeschlagen,Échec de la correction de version,
+busy.rolling_back_cache,"Rolling back from cache ({0}/{1})...",Rollback aus Cache ({0}/{1})…,Restauration depuis le cache ({0}/{1})…,
+busy.downloading_correct,"Downloading correct versions ({0}/{1})...",Richtige Versionen werden heruntergeladen ({0}/{1})…,Téléchargement des versions correctes ({0}/{1})…,
+status.applied_correct_version,Applied correct version for {0} mod(s).,Richtige Version für {0} Mod(s) angewendet.,Version correcte appliquée pour {0} mod(s).,
+status.applied_correct_version_failed,"Applied correct version for {0} mod(s). {1} failed.","Richtige Version für {0} Mod(s) angewendet. {1} fehlgeschlagen.","Version correcte appliquée pour {0} mod(s). {1} échoué(s).",
+dialog.remove_title,Remove Mod,Mod entfernen,Supprimer le mod,
+dialog.remove_message,"Remove ''{0}'' from the list? If the mod folder still exists on disk it will also be deleted.","„{0}"" aus der Liste entfernen? Falls der Mod-Ordner noch auf der Festplatte existiert wird dieser ebenfalls gelöscht.","Supprimer « {0} » de la liste ? Si le dossier du mod existe encore sur le disque, il sera également supprimé.",
+status.removed,Removed: {0},Entfernt: {0},Supprimé : {0},
+toolbar.start_game,▶ Start Game,▶ Spiel starten,▶ Lancer le jeu,
+toolbar.start_game.tooltip,Launch Derail Valley via Steam,Derail Valley über Steam starten,Lancer Derail Valley via Steam,
+status.game_starting,Starting game via Steam...,Spiel wird über Steam gestartet…,Lancement du jeu via Steam…,
+button.install_companion,+ Install Companion Mod,+ Begleitmod installieren,+ Installer le mod compagnon,
+button.activate_companion,Activate Companion Mod,Begleitmod aktivieren,Activer le mod compagnon,
+button.activated_companion,✓ Activated Companion Mod,✓ Begleitmod aktiviert,✓ Mod compagnon activé,
+button.companion.tooltip,"Install DV Mod Profiles to associate mod profiles with save files and to provide Steam Cloud synchronization of profiles and the configured mod settings","Installiere DV Mod Profiles um Modprofile mit Spielständen zu verknüpfen und Steam-Cloud-Synchronisation von Profilen und konfigurierten Mod-Einstellungen bereitzustellen","Installez DV Mod Profiles pour associer les profils de mods aux fichiers de sauvegarde et fournir la synchronisation Steam Cloud des profils et des paramètres de mod configurés",
+busy.installing_companion,Installing companion mod...,Begleitmod wird installiert…,Installation du mod compagnon…,
+busy.activating_companion,Activating companion mod...,Begleitmod wird aktiviert…,Activation du mod compagnon…,
+status.companion_installed,DV Mod Profiles installed successfully.,DV Mod Profiles erfolgreich installiert.,DV Mod Profiles installé avec succès.,
+status.companion_activated,DV Mod Profiles activated.,DV Mod Profiles aktiviert.,DV Mod Profiles activé.,
+status.companion_install_failed,Failed to install companion mod.,Installation der Begleitmod fehlgeschlagen.,Échec de l'installation du mod compagnon.,
+drop.hint.install,Drop .zip files here to install,.zip-Dateien hier ablegen zum Installieren,Déposez des fichiers .zip ici pour installer,
+app.version,DV ModManager v{0},DV ModManager v{0},DV ModManager v{0},
+app.version_update,DV ModManager v{0} → v{1},DV ModManager v{0} → v{1},DV ModManager v{0} → v{1},
+tooltip.click_release_page,Click to open release page,Klicken um Release-Seite zu öffnen,Cliquez pour ouvrir la page de release,
+group.mod_count_format,{0} mod(s),{0} Mod(s),{0} mod(s),
+dialog.new_group,New Group,Neue Gruppe,Nouveau groupe,
+file.filter.mod_archives,Mod Archives & Profiles,Mod-Archive & Profile,Archives de mod & Profils,
+file.filter.json_profile,JSON Profile,JSON-Profil,Profil JSON,
+file.filter.zip_modpack,ZIP Modpack,ZIP-Modpack,Modpack ZIP,
+dialog.select_game_folder,Select Derail Valley installation folder,Derail Valley-Installationsordner auswählen,Sélectionner le dossier d'installation de Derail Valley,
+dialog.select_game_folder_short,Select Derail Valley folder,Derail Valley-Ordner auswählen,Sélectionner le dossier Derail Valley,
+dialog.error,Error,Fehler,Erreur,
+dialog.invalid_game_path,Invalid game path — could not find DerailValley_Data folder.,Ungültiger Spielpfad – DerailValley_Data-Ordner nicht gefunden.,Chemin du jeu invalide — impossible de trouver le dossier DerailValley_Data.,
+dialog.select_storage_folder,Select storage folder,Speicherordner auswählen,Sélectionner le dossier de stockage,
+dialog.delete_profile_title,Delete Profile,Profil löschen,Supprimer le profil,
+dialog.delete_profile_message,Delete profile ''{0}''?,Profil „{0}" löschen?,Supprimer le profil « {0} » ?,
+dialog.import_profile,Import Profile,Profil importieren,Importer le profil,
+dialog.import_failed,Import Failed,Import fehlgeschlagen,Échec de l'importation,
+dialog.failed_downloads_title,Download Failed,Download fehlgeschlagen,Échec du téléchargement,
+dialog.failed_downloads_message,The following mods could not be downloaded from GitHub:,Die folgenden Mods konnten nicht von GitHub heruntergeladen werden:,Les mods suivants n'ont pas pu être téléchargés depuis GitHub :,
+dialog.failed_download_no_link, • {0} (no link available), • {0} (kein Link verfügbar), • {0} (aucun lien disponible),
+dialog.failed_download_open_hint,"Click ""Open on Nexus ({0})"" to open their pages in the browser.","Klicke auf ""Auf Nexus öffnen ({0})"" um die Seiten im Browser zu öffnen.","Cliquez sur ""Ouvrir sur Nexus ({0})"" pour ouvrir leurs pages dans le navigateur.",
+button.open_on_nexus,Open on Nexus,Auf Nexus öffnen,Ouvrir sur Nexus,
+button.open_on_nexus_count,Open on Nexus ({0}),Auf Nexus öffnen ({0}),Ouvrir sur Nexus ({0}),
+status.game_running_locked,Game is running. Mod changes are locked.,Spiel läuft. Mod-Änderungen sind gesperrt.,Le jeu est en cours. Les modifications de mods sont verrouillées.,
+status.game_stopped_unlocked,Game stopped. Mod changes unlocked.,Spiel gestoppt. Mod-Änderungen entsperrt.,Jeu arrêté. Modifications de mods déverrouillées.,
+status.activated_simple,Activated {0}/{1} mod(s).,{0}/{1} Mod(s) aktiviert.,{0}/{1} mod(s) activé(s).,
+status.activated_simple_missing_deps,Activated {0}/{1} — missing deps: {2},Aktiviert {0}/{1} – fehlende Abhängigkeiten: {2},Activé {0}/{1} — dépendances manquantes : {2},
+status.updated_n_total,Updated {0}/{1} mod(s).,{0}/{1} Mod(s) aktualisiert.,{0}/{1} mod(s) mis à jour.,
+status.deactivated_simple,Deactivated {0}/{1} mod(s).,{0}/{1} Mod(s) deaktiviert.,{0}/{1} mod(s) désactivé(s).,
+dialog.download_will_auto_github,Will auto-download from GitHub ({0}):,Wird automatisch von GitHub heruntergeladen ({0}):,Sera téléchargé automatiquement depuis GitHub ({0}) :,
+dialog.download_will_open_browser,Will open in browser for manual download ({0}):,Wird im Browser zur manuellen Installation geöffnet ({0}):,Sera ouvert dans le navigateur pour téléchargement manuel ({0}) :,
+dialog.download_proceed,Proceed with downloads?,Mit Downloads fortfahren?,Procéder aux téléchargements ?,
+status.downloaded_n_failed,Downloaded {0} mod(s). {1} could not be auto-downloaded.,{0} Mod(s) heruntergeladen. {1} konnten nicht automatisch heruntergeladen werden.,{0} mod(s) téléchargé(s). {1} n'ont pas pu être téléchargés automatiquement.,
+busy.uninstalling_mod,Uninstalling {0}...,Deinstalliere {0}…,Désinstallation de {0}…,
+busy.activating_dependency,Activating dependency {0}...,Aktiviere Abhängigkeit {0}…,Activation de la dépendance {0}…,
+busy.unloading_all,Unloading all mods...,Entlade alle Mods…,Déchargement de tous les mods…,
diff --git a/DVModManager/Services/DialogService.cs b/DVModManager/Services/DialogService.cs
new file mode 100644
index 0000000..4a94fcc
--- /dev/null
+++ b/DVModManager/Services/DialogService.cs
@@ -0,0 +1,388 @@
+using Avalonia.Controls;
+using Avalonia.Media;
+using Avalonia.Platform.Storage;
+using DVModManager.Models;
+
+namespace DVModManager.Services;
+
+public class DialogService : IDialogService
+{
+ private Window? _owner;
+ private readonly ILocalizationService _loc;
+
+ public DialogService(ILocalizationService loc)
+ {
+ _loc = loc;
+ }
+
+ 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> OpenFilesAsync(string title, string filterName, string[] extensions)
+ {
+ if (_owner == null) return [];
+ var provider = TopLevel.GetTopLevel(_owner)?.StorageProvider;
+ if (provider == null) return [];
+
+ var files = await provider.OpenFilePickerAsync(new FilePickerOpenOptions
+ {
+ Title = title,
+ AllowMultiple = true,
+ FileTypeFilter =
+ [
+ new FilePickerFileType(filterName)
+ {
+ Patterns = extensions.Select(e => e.StartsWith("*.") ? e : "*." + e.TrimStart('.')).ToList()
+ }
+ ]
+ });
+
+ return files.Select(f => f.Path.LocalPath).ToList();
+ }
+
+ 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,
+ MaxHeight = 600,
+ SizeToContent = SizeToContent.Height,
+ 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,
+ MaxHeight = 600,
+ SizeToContent = SizeToContent.Height,
+ CanResize = false,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner
+ };
+
+ // 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 Avalonia.Controls.Control BuildMessageContent(string message, Action? closeCallback)
+ {
+ var btn = new Button { Content = _loc.GetString("dialog.button.ok"), HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center };
+ btn.Click += (_, _) => closeCallback?.Invoke(true);
+
+ return new StackPanel
+ {
+ Margin = new Avalonia.Thickness(20),
+ Spacing = 16,
+ Children =
+ {
+ new ScrollViewer
+ {
+ MaxHeight = 280,
+ Content = new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap }
+ },
+ btn
+ }
+ };
+ }
+
+ private Avalonia.Controls.Control BuildConfirmContent(string message, Action callback)
+ {
+ var okBtn = new Button { Content = _loc.GetString("dialog.button.yes"), Width = 80 };
+ var cancelBtn = new Button { Content = _loc.GetString("dialog.button.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 ScrollViewer
+ {
+ MaxHeight = 280,
+ Content = new TextBlock { Text = message, TextWrapping = Avalonia.Media.TextWrapping.Wrap }
+ },
+ buttons
+ }
+ };
+ }
+
+ public async Task ShowExportOptionsAsync(string title)
+ {
+ if (_owner == null) return ExportOption.Cancel;
+
+ var tcs = new TaskCompletionSource();
+
+ var jsonBtn = new Button { Content = _loc.GetString("export.button.json") };
+ var zipBtn = new Button { Content = _loc.GetString("export.button.zip") };
+ var cancelBtn = new Button { Content = _loc.GetString("settings.button.cancel") };
+
+ jsonBtn.Click += (_, _) => tcs.TrySetResult(ExportOption.Json);
+ zipBtn.Click += (_, _) => tcs.TrySetResult(ExportOption.Zip);
+ cancelBtn.Click += (_, _) => tcs.TrySetResult(ExportOption.Cancel);
+
+ var buttons = new StackPanel
+ {
+ Orientation = Avalonia.Layout.Orientation.Horizontal,
+ HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
+ Spacing = 12,
+ Children = { jsonBtn, zipBtn, cancelBtn }
+ };
+
+ var dialog = new Window
+ {
+ Title = title,
+ Width = 360,
+ SizeToContent = SizeToContent.Height,
+ CanResize = false,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner,
+ Content = new StackPanel
+ {
+ Margin = new Avalonia.Thickness(20),
+ Spacing = 16,
+ Children = { buttons }
+ }
+ };
+
+ dialog.Closing += (_, _) => tcs.TrySetResult(ExportOption.Cancel);
+ _ = dialog.ShowDialog(_owner);
+ var result = await tcs.Task;
+ if (dialog.IsVisible) dialog.Close();
+ return result;
+ }
+
+ public async Task ShowModpackImportConfirmAsync(string title, ModProfile profile)
+ {
+ if (_owner == null) return false;
+
+ var tcs = new TaskCompletionSource();
+
+ var sb = new System.Text.StringBuilder();
+ foreach (var mod in profile.Mods)
+ sb.AppendLine($" \u2022 {mod.ModId} v{mod.Version}");
+
+ var preamble = new TextBlock
+ {
+ Text = _loc.GetString("import.modpack.preamble"),
+ TextWrapping = TextWrapping.Wrap
+ };
+ var modList = new TextBlock
+ {
+ Text = sb.ToString().TrimEnd(),
+ FontFamily = new FontFamily("Consolas,Courier New,monospace"),
+ TextWrapping = TextWrapping.Wrap
+ };
+ var warning = new TextBlock
+ {
+ Text = _loc.GetString("import.modpack.security_warning"),
+ TextWrapping = TextWrapping.Wrap,
+ Foreground = Brushes.OrangeRed
+ };
+
+ var importBtn = new Button { Content = _loc.GetString("import.button.confirm") };
+ var cancelBtn = new Button { Content = _loc.GetString("settings.button.cancel") };
+
+ importBtn.Click += (_, _) => tcs.TrySetResult(true);
+ cancelBtn.Click += (_, _) => tcs.TrySetResult(false);
+
+ var buttons = new StackPanel
+ {
+ Orientation = Avalonia.Layout.Orientation.Horizontal,
+ HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
+ Spacing = 12,
+ Children = { importBtn, cancelBtn }
+ };
+
+ var content = new StackPanel
+ {
+ Margin = new Avalonia.Thickness(20),
+ Spacing = 16,
+ Children =
+ {
+ preamble,
+ new ScrollViewer { MaxHeight = 200, Content = modList },
+ warning,
+ buttons
+ }
+ };
+
+ var dialog = new Window
+ {
+ Title = title,
+ Width = 460,
+ MaxHeight = 600,
+ SizeToContent = SizeToContent.Height,
+ CanResize = false,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner,
+ Content = content
+ };
+
+ dialog.Closing += (_, _) => tcs.TrySetResult(false);
+ _ = dialog.ShowDialog(_owner);
+ var result = await tcs.Task;
+ if (dialog.IsVisible) dialog.Close();
+ return result;
+ }
+
+ public async Task ShowFailedDownloadsAsync(string title, IReadOnlyList<(string ModId, string? HomePageUrl)> failedMods)
+ {
+ if (_owner == null) return false;
+
+ bool openNexus = false;
+ var tcs = new TaskCompletionSource();
+
+ var nexusBtn = new Button { Content = _loc.GetString("button.open_on_nexus"), Width = 130 };
+ var cancelBtn = new Button { Content = _loc.GetString("settings.button.cancel"), Width = 80 };
+
+ nexusBtn.Click += (_, _) => { openNexus = true; tcs.TrySetResult(true); };
+ cancelBtn.Click += (_, _) => { openNexus = false; tcs.TrySetResult(false); };
+
+ var buttons = new StackPanel
+ {
+ Orientation = Avalonia.Layout.Orientation.Horizontal,
+ HorizontalAlignment = Avalonia.Layout.HorizontalAlignment.Center,
+ Spacing = 12,
+ Children = { nexusBtn, cancelBtn }
+ };
+
+ int nexusCount = failedMods.Count(m => !string.IsNullOrEmpty(m.HomePageUrl));
+ nexusBtn.Content = nexusCount > 0 ? _loc.GetString("button.open_on_nexus_count", nexusCount) : _loc.GetString("button.open_on_nexus");
+ nexusBtn.Width = double.NaN; // auto-width to fit content
+
+ var lines = new System.Text.StringBuilder();
+ lines.AppendLine(_loc.GetString("dialog.failed_downloads_message"));
+ foreach (var (modId, homePageUrl) in failedMods)
+ {
+ bool hasLink = !string.IsNullOrEmpty(homePageUrl);
+ lines.AppendLine(hasLink ? $" \u2022 {modId}" : _loc.GetString("dialog.failed_download_no_link", modId));
+ }
+ if (nexusCount > 0)
+ lines.AppendLine("\n" + _loc.GetString("dialog.failed_download_open_hint", nexusCount));
+ else
+ nexusBtn.IsEnabled = false;
+
+ var content = new StackPanel
+ {
+ Margin = new Avalonia.Thickness(20),
+ Spacing = 16,
+ Children =
+ {
+ new ScrollViewer
+ {
+ MaxHeight = 280,
+ Content = new TextBlock { Text = lines.ToString().TrimEnd(), TextWrapping = Avalonia.Media.TextWrapping.Wrap }
+ },
+ buttons
+ }
+ };
+
+ var dialog = new Window
+ {
+ Title = title,
+ Width = 460,
+ MaxHeight = 600,
+ SizeToContent = SizeToContent.Height,
+ CanResize = false,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner,
+ Content = content
+ };
+
+ dialog.Closing += (_, _) => tcs.TrySetResult(false);
+
+ _ = dialog.ShowDialog(_owner);
+ openNexus = await tcs.Task;
+ if (dialog.IsVisible) dialog.Close();
+ return openNexus;
+ }
+}
diff --git a/DVModManager/Services/FileLoggerProvider.cs b/DVModManager/Services/FileLoggerProvider.cs
new file mode 100644
index 0000000..5b1888b
--- /dev/null
+++ b/DVModManager/Services/FileLoggerProvider.cs
@@ -0,0 +1,94 @@
+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;
+ try { Directory.CreateDirectory(logDirectory); }
+ catch { /* log directory unavailable – file logging silently disabled */ }
+
+ _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..1382166
--- /dev/null
+++ b/DVModManager/Services/GitHubModsService.cs
@@ -0,0 +1,229 @@
+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.Timeout = TimeSpan.FromSeconds(15);
+ 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)
+ {
+ try
+ {
+ // GitHub release asset downloads are plain HTTPS — use HttpClient
+ using var http = new HttpClient();
+ http.Timeout = TimeSpan.FromSeconds(60);
+ 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;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error downloading release asset from {Url}", downloadUrl);
+ throw;
+ }
+ }
+
+ // ── 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..f3c30f0
--- /dev/null
+++ b/DVModManager/Services/IDialogService.cs
@@ -0,0 +1,23 @@
+using Avalonia.Controls;
+using DVModManager.Models;
+
+namespace DVModManager.Services;
+
+public enum ExportOption { Json, Zip, Cancel }
+
+public interface IDialogService
+{
+ void SetOwner(Window owner);
+ Task PickFolderAsync(string title);
+ Task OpenFileAsync(string title, string filterName, string[] extensions);
+ Task> OpenFilesAsync(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);
+ /// Shows a list of failed downloads with "Open on Nexus" and "Cancel" buttons. Returns true if the user chose "Open on Nexus".
+ Task ShowFailedDownloadsAsync(string title, IReadOnlyList<(string ModId, string? HomePageUrl)> failedMods);
+ /// Shows export format choice: JSON, ZIP, or Cancel.
+ Task ShowExportOptionsAsync(string title);
+ /// Shows the mod list from a profile with a security warning. Returns true if the user confirmed the import.
+ Task ShowModpackImportConfirmAsync(string title, ModProfile profile);
+}
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/ILocalizationService.cs b/DVModManager/Services/ILocalizationService.cs
new file mode 100644
index 0000000..ba2e00b
--- /dev/null
+++ b/DVModManager/Services/ILocalizationService.cs
@@ -0,0 +1,48 @@
+namespace DVModManager.Services;
+
+///
+/// Provides localization/translation services with key-based lookup, live language switching,
+/// and fallback to English for missing translations.
+///
+public interface ILocalizationService
+{
+ /// Gets the currently active language code (e.g., "en", "de", "fr").
+ string CurrentLanguage { get; }
+
+ /// Gets a list of supported language codes.
+ IReadOnlyList SupportedLanguages { get; }
+
+ ///
+ /// Gets the localized string for the given key in the current language,
+ /// falling back to English if unavailable, or returning the key itself if not found anywhere.
+ ///
+ /// The translation key (e.g., "btn.save", "status.game_detected").
+ /// The translated string, English fallback, or the key name if missing entirely.
+ string GetString(string key);
+
+ ///
+ /// Indexer shorthand for XAML binding paths like [toolbar.save_profile].
+ ///
+ string this[string key] { get; }
+
+ ///
+ /// Gets the localized string with format arguments applied (like string.Format).
+ /// Falls back to English if unavailable, or returns the key if not found.
+ ///
+ /// The translation key.
+ /// Format arguments.
+ /// The formatted translated string, English fallback, or key if missing.
+ string GetString(string key, params object?[] args);
+
+ ///
+ /// Switches the active language and notifies all subscribers.
+ /// Language must be in SupportedLanguages or falls back to "en".
+ ///
+ /// The language code to switch to.
+ void SetLanguage(string languageCode);
+
+ ///
+ /// Raised when the active language changes, allowing UI elements to refresh.
+ ///
+ event EventHandler? LanguageChanged;
+}
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..0e2985e
--- /dev/null
+++ b/DVModManager/Services/IModInstallService.cs
@@ -0,0 +1,16 @@
+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 InstallFromFolderAsync(string modFolderPath, string gamePath, string storagePath, bool activate = false, 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 DownloadAndInstallFromUrlAsync(string downloadUrl, 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..d5fc8ab
--- /dev/null
+++ b/DVModManager/Services/IProfileService.cs
@@ -0,0 +1,24 @@
+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);
+ Task ExportProfileAsZipAsync(ModProfile profile, string gamePath, string zipPath);
+ /// Returns a profile name that doesn't collide with existing files, appending (2), (3) etc. as needed.
+ Task GetUniqueProfileNameAsync(string name, string profilesPath);
+ ProfileDiff ComputeDiff(ModProfile profile, IReadOnlyList currentMods);
+}
+
+public record ProfileDiff(
+ IReadOnlyList ToActivate,
+ IReadOnlyList ToDeactivate,
+ IReadOnlyList<(string ModId, string FromVersion, string ToVersion)> ToRollback,
+ IReadOnlyList ToDownload,
+ IReadOnlyList ToRedownload);
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/LocalizationService.cs b/DVModManager/Services/LocalizationService.cs
new file mode 100644
index 0000000..b749e8c
--- /dev/null
+++ b/DVModManager/Services/LocalizationService.cs
@@ -0,0 +1,271 @@
+using System.Globalization;
+using System.ComponentModel;
+
+namespace DVModManager.Services;
+
+///
+/// Loads translations from an embedded CSV resource (key,en,de,fr,... format)
+/// and provides runtime key lookup with fallback to English.
+/// Implements INotifyPropertyChanged to notify UI when language changes.
+///
+public class LocalizationService : ILocalizationService, INotifyPropertyChanged
+{
+ private readonly Dictionary> _translations = [];
+ private readonly List _languages = [];
+ private string _currentLanguage = "en";
+
+ public string CurrentLanguage
+ {
+ get => _currentLanguage;
+ private set
+ {
+ if (_currentLanguage != value)
+ {
+ _currentLanguage = value;
+ OnPropertyChanged(nameof(CurrentLanguage));
+ }
+ }
+ }
+
+ public IReadOnlyList SupportedLanguages => _languages.AsReadOnly();
+
+ public event EventHandler? LanguageChanged;
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public LocalizationService()
+ {
+ LoadTranslationsFromEmbeddedCsv();
+ }
+
+ ///
+ /// Loads translations from the embedded CSV resource "DVModManager.Resources.strings.csv".
+ /// Expected format: key,en,de,fr,...
+ /// Handles BOM, empty cells, and missing language columns gracefully.
+ ///
+ private void LoadTranslationsFromEmbeddedCsv()
+ {
+ _translations.Clear();
+ _languages.Clear();
+ _languages.Add("en"); // English always supported as fallback
+
+ try
+ {
+ var assembly = typeof(LocalizationService).Assembly;
+ const string expectedResourceName = "DVModManager.Resources.strings.csv";
+ var resourceName = expectedResourceName;
+
+ // Resolve resource name defensively in case namespace/publish settings change.
+ // This avoids silent failures where the exact expected name cannot be found.
+ var stream = assembly.GetManifestResourceStream(resourceName);
+ if (stream == null)
+ {
+ resourceName = assembly.GetManifestResourceNames()
+ .FirstOrDefault(n => n.EndsWith("Resources.strings.csv", StringComparison.OrdinalIgnoreCase)
+ || n.EndsWith("strings.csv", StringComparison.OrdinalIgnoreCase))
+ ?? expectedResourceName;
+ stream = assembly.GetManifestResourceStream(resourceName);
+ }
+
+ using (stream)
+ {
+ if (stream == null)
+ {
+ var names = string.Join(", ", assembly.GetManifestResourceNames());
+ Console.Error.WriteLine($"Warning: Embedded localization CSV not found. Expected '{expectedResourceName}'. Available resources: {names}");
+ return;
+ }
+
+ using (var reader = new StreamReader(stream))
+ {
+ // Parse header row to identify language columns
+ var headerLine = reader.ReadLine();
+ if (headerLine == null) return;
+
+ // Be defensive about BOM and zero-width marks without dropping regular characters.
+ headerLine = headerLine.TrimStart('\uFEFF');
+
+ var headers = ParseCsvLine(headerLine).Select(h => h.Trim()).ToArray();
+ if (headers.Length == 0)
+ {
+ Console.Error.WriteLine("CSV header is empty. Localization unavailable.");
+ return;
+ }
+
+ var keyHeader = headers[0]
+ .Trim('\uFEFF', '\0', ' ', '\t', '\r', '\n');
+ if (!keyHeader.Equals("key", StringComparison.OrdinalIgnoreCase))
+ {
+ Console.Error.WriteLine($"Warning: CSV header first column is '{headers[0]}' instead of 'key'. Proceeding anyway.");
+ }
+
+ // Extract language codes from header (skip "key" column)
+ var languages = headers.Skip(1).ToList();
+ _languages.Clear();
+ _languages.Add("en"); // Always available
+ foreach (var lang in languages)
+ {
+ if (!string.IsNullOrWhiteSpace(lang) && lang != "en" && !_languages.Contains(lang))
+ _languages.Add(lang);
+ }
+
+ // Parse data rows
+ string? line;
+ while ((line = reader.ReadLine()) != null)
+ {
+ if (string.IsNullOrWhiteSpace(line)) continue;
+
+ // Be defensive about stray BOM on row starts.
+ line = line.TrimStart('\uFEFF');
+
+ var parts = ParseCsvLine(line);
+ if (parts.Length < 1) continue;
+
+ var key = parts[0].Trim();
+ if (string.IsNullOrWhiteSpace(key)) continue;
+
+ // Initialize entry for this key
+ if (!_translations.ContainsKey(key))
+ _translations[key] = [];
+
+ // Populate each language column (index 0 is key, so language i is at parts[i+1])
+ for (int i = 0; i < languages.Count && i + 1 < parts.Length; i++)
+ {
+ var langCode = languages[i].Trim();
+ var value = parts[i + 1].Trim();
+
+ if (!string.IsNullOrWhiteSpace(langCode) && !string.IsNullOrWhiteSpace(value))
+ {
+ _translations[key][langCode] = UnescapeCsvValue(value);
+ }
+ }
+ }
+
+ Console.Error.WriteLine($"Localization loaded from '{resourceName}'. Languages: {string.Join(", ", _languages)}. Keys: {_translations.Count}.");
+ }
+ }
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine($"Error loading embedded localization CSV: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Parses one CSV row and supports quoted cells with commas and escaped quotes.
+ ///
+ private static string[] ParseCsvLine(string line)
+ {
+ var result = new List();
+ var current = new System.Text.StringBuilder();
+ bool inQuotes = false;
+
+ for (int i = 0; i < line.Length; i++)
+ {
+ var ch = line[i];
+ if (ch == '"')
+ {
+ if (inQuotes && i + 1 < line.Length && line[i + 1] == '"')
+ {
+ // Escaped quote inside a quoted field.
+ current.Append('"');
+ i++;
+ }
+ else
+ {
+ inQuotes = !inQuotes;
+ }
+ continue;
+ }
+
+ if (ch == ',' && !inQuotes)
+ {
+ result.Add(current.ToString());
+ current.Clear();
+ continue;
+ }
+
+ current.Append(ch);
+ }
+
+ result.Add(current.ToString());
+ return result.ToArray();
+ }
+
+ ///
+ /// Unescapes CSV-quoted values (handles quoted newlines, commas, escaped quotes, etc.).
+ /// For now, assumes values are not quoted unless we detect quotes during parsing.
+ ///
+ private static string UnescapeCsvValue(string value)
+ {
+ // Basic unescaping: if value was quoted, remove outer quotes and unescape inner quotes
+ if (value.StartsWith("\"") && value.EndsWith("\""))
+ {
+ value = value[1..^1];
+ value = value.Replace("\"\"", "\"");
+ }
+ return value;
+ }
+
+ public string GetString(string key)
+ {
+ if (string.IsNullOrEmpty(key)) return "";
+
+ // Try current language
+ if (_translations.TryGetValue(key, out var langDict))
+ {
+ if (langDict.TryGetValue(_currentLanguage, out var translation))
+ return translation;
+
+ // Fall back to English if current language not found
+ if (langDict.TryGetValue("en", out var englishTranslation))
+ return englishTranslation;
+ }
+
+ // Return key name as last resort
+ return key;
+ }
+
+ ///
+ /// Indexer for XAML binding: {Binding [key], Source={StaticResource Loc}}
+ /// When language changes, "Item[]" PropertyChanged fires to refresh all indexer bindings.
+ ///
+ public string this[string key] => GetString(key);
+
+ public string GetString(string key, params object?[] args)
+ {
+ var template = GetString(key);
+ try
+ {
+ return string.Format(CultureInfo.CurrentCulture, template, args);
+ }
+ catch
+ {
+ // If format fails, return template as-is
+ return template;
+ }
+ }
+
+ public void SetLanguage(string languageCode)
+ {
+ if (string.IsNullOrWhiteSpace(languageCode))
+ languageCode = "en";
+
+ // Normalize to supported language or fall back to English
+ if (!_languages.Contains(languageCode))
+ languageCode = "en";
+
+ if (languageCode == _currentLanguage)
+ return; // No change
+
+ CurrentLanguage = languageCode; // Use property setter to trigger PropertyChanged
+ // Notify all indexer bindings ([key]) that every key's value has changed
+ // "Item[]" is the standard PropertyChanged name for indexers (defined in Binding.IndexerName in System.Windows.Data)
+ OnPropertyChanged("Item[]");
+ LanguageChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ protected void OnPropertyChanged(string propertyName)
+ {
+ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+}
diff --git a/DVModManager/Services/ModDiscoveryService.cs b/DVModManager/Services/ModDiscoveryService.cs
new file mode 100644
index 0000000..ab8bd22
--- /dev/null
+++ b/DVModManager/Services/ModDiscoveryService.cs
@@ -0,0 +1,142 @@
+using System.Text.Json;
+using DVModManager.Helpers;
+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 = InfoJsonLocator.Locate(folderPath);
+
+ ModInfo mod;
+
+ if (infoPath == null)
+ {
+ // 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 CancellationTokenSource? _debounceCts;
+
+ private void OnFileSystemChange(object sender, FileSystemEventArgs e)
+ {
+ // Debounce: cancel any pending fire, schedule a new one after 500ms.
+ // This ensures rapid changes (e.g., unzipping) only fire once.
+ _debounceCts?.Cancel();
+ _debounceCts = new CancellationTokenSource();
+ var token = _debounceCts.Token;
+ _ = Task.Delay(500, token).ContinueWith(t =>
+ {
+ if (!t.IsCanceled)
+ ModsChanged?.Invoke(this, EventArgs.Empty);
+ }, TaskScheduler.Default);
+ }
+
+ 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..cdd9097
--- /dev/null
+++ b/DVModManager/Services/ModInstallService.cs
@@ -0,0 +1,575 @@
+using System.IO.Compression;
+using System.Text.Json;
+using DVModManager.Helpers;
+using DVModManager.Models;
+using Microsoft.Extensions.Logging;
+
+namespace DVModManager.Services;
+
+public class ModInstallService : IModInstallService
+{
+ private readonly IVersionCacheService _versionCache;
+ private readonly ISettingsService _settings;
+ private readonly ILogger _logger;
+
+ private static readonly JsonSerializerOptions JsonOptions = new()
+ {
+ PropertyNameCaseInsensitive = true,
+ AllowTrailingCommas = true
+ };
+
+ public ModInstallService(IVersionCacheService versionCache, ISettingsService settings, ILogger logger)
+ {
+ _versionCache = versionCache;
+ _settings = settings;
+ _logger = logger;
+ }
+
+ private bool ShouldArchive => _settings.Settings.EnableVersionArchiving;
+
+ // ── 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 = InfoJsonLocator.Locate(modRoot);
+ if (infoPath == null)
+ {
+ Directory.Delete(tempDir, true);
+ _logger.LogError("No Info.json found in archive {Archive}", archivePath);
+ return null;
+ }
+ 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 = InfoJsonLocator.Locate(targetDir);
+ if (existingInfo != null)
+ {
+ try
+ {
+ var existingMod = JsonSerializer.Deserialize(
+ await File.ReadAllTextAsync(existingInfo, ct), JsonOptions);
+ if (existingMod != null)
+ {
+ existingMod.FolderPath = targetDir;
+ if (ShouldArchive)
+ 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;
+ }
+ }
+
+ // ── Install from folder ─────────────────────────────────────────────────────────────
+
+ public async Task InstallFromFolderAsync(
+ string modFolderPath, string gamePath, string storagePath,
+ bool activate = false, CancellationToken ct = default)
+ {
+ try
+ {
+ var infoPath = InfoJsonLocator.Locate(modFolderPath);
+ if (infoPath == null)
+ {
+ _logger.LogError("No Info.json found in folder {Folder}", modFolderPath);
+ return null;
+ }
+
+ var json = await File.ReadAllTextAsync(infoPath, ct);
+ var modInfo = JsonSerializer.Deserialize(json, JsonOptions);
+ if (modInfo == null) return null;
+
+ var targetDir = activate
+ ? Path.Combine(gamePath, "Mods", modInfo.Id)
+ : Path.Combine(gamePath, "Mods.inactive", modInfo.Id);
+
+ Directory.CreateDirectory(Path.GetDirectoryName(targetDir)!);
+
+ if (Directory.Exists(targetDir))
+ {
+ var existingInfoPath = InfoJsonLocator.Locate(targetDir);
+ if (existingInfoPath != null)
+ {
+ try
+ {
+ var existingMod = JsonSerializer.Deserialize(
+ await File.ReadAllTextAsync(existingInfoPath, ct), JsonOptions);
+ if (existingMod != null)
+ {
+ existingMod.FolderPath = targetDir;
+ if (ShouldArchive)
+ await _versionCache.ArchiveCurrentVersionAsync(existingMod, storagePath);
+ }
+ }
+ catch { /* archive failure is non-fatal */ }
+ }
+ Directory.Delete(targetDir, true);
+ }
+
+ await Task.Run(() => CopyDirectoryRecursive(modFolderPath, targetDir), ct);
+
+ modInfo.FolderPath = targetDir;
+ modInfo.IsActive = activate;
+ modInfo.State = activate ? ModState.Active : ModState.Inactive;
+
+ _logger.LogInformation("Installed mod {Id} v{Version} from folder", modInfo.Id, modInfo.Version);
+ return modInfo;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to install from folder {Folder}", modFolderPath);
+ 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 (ShouldArchive && 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 = InfoJsonLocator.Locate(currentPath);
+ if (infoPath != null)
+ {
+ var currentMod = JsonSerializer.Deserialize(
+ await File.ReadAllTextAsync(infoPath, ct), JsonOptions);
+ if (currentMod != null)
+ {
+ currentMod.FolderPath = currentPath;
+ if (ShouldArchive)
+ 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
+ if (ShouldArchive)
+ 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;
+ }
+ }
+
+ // ── Download and install from URL ──────────────────────────────────────────────
+
+ public async Task DownloadAndInstallFromUrlAsync(
+ string downloadUrl, string gamePath, string storagePath,
+ IProgress? progress = null, CancellationToken ct = default)
+ {
+ try
+ {
+ var downloadDir = Path.Combine(storagePath, "downloads", "profile_imports");
+ Directory.CreateDirectory(downloadDir);
+ var downloadPath = Path.Combine(downloadDir, $"{Guid.NewGuid()}.zip");
+
+ using var http = new HttpClient();
+ http.Timeout = TimeSpan.FromSeconds(60);
+ 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 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);
+ }
+ }
+
+ var result = await InstallFromArchiveAsync(downloadPath, gamePath, storagePath, activate: false, ct);
+
+ try { File.Delete(downloadPath); } catch { /* best-effort cleanup */ }
+
+ return result;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "DownloadAndInstall failed for URL {Url}", downloadUrl);
+ return null;
+ }
+ }
+
+ // ── 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 (InfoJsonLocator.Locate(extractedDir) != null) return extractedDir;
+
+ // Check one level deep (common pattern: archive contains a single mod folder)
+ foreach (var subDir in Directory.GetDirectories(extractedDir))
+ {
+ if (InfoJsonLocator.Locate(subDir) != null) 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..f81bb6e
--- /dev/null
+++ b/DVModManager/Services/NexusModsService.cs
@@ -0,0 +1,154 @@
+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 System.Text.Json.JsonElement? UpdatedTimeRaw { get; set; }
+
+ [System.Text.Json.Serialization.JsonIgnore]
+ public long? UpdatedTime
+ {
+ get
+ {
+ if (UpdatedTimeRaw is not { } elem) return null;
+ try
+ {
+ return elem.ValueKind switch
+ {
+ System.Text.Json.JsonValueKind.Number => elem.GetInt64(),
+ System.Text.Json.JsonValueKind.String
+ => long.TryParse(elem.GetString(), out var v) ? v : null,
+ _ => null
+ };
+ }
+ catch { return null; }
+ }
+ }
+ }
+}
diff --git a/DVModManager/Services/ProfileService.cs b/DVModManager/Services/ProfileService.cs
new file mode 100644
index 0000000..f92dff6
--- /dev/null
+++ b/DVModManager/Services/ProfileService.cs
@@ -0,0 +1,172 @@
+using System.IO.Compression;
+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 async Task ExportProfileAsZipAsync(ModProfile profile, string gamePath, string zipPath)
+ {
+ await Task.Run(() =>
+ {
+ using var archive = System.IO.Compression.ZipFile.Open(zipPath, System.IO.Compression.ZipArchiveMode.Create);
+
+ // Add profile.json at root
+ var profileEntry = archive.CreateEntry("profile.json");
+ using (var entryStream = profileEntry.Open())
+ {
+ var json = System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(profile, JsonOptions);
+ entryStream.Write(json, 0, json.Length);
+ }
+
+ // Add mod files under mods//
+ foreach (var mod in profile.Mods)
+ {
+ var activePath = Path.Combine(gamePath, "Mods", mod.ModId);
+ var inactivePath = Path.Combine(gamePath, "Mods.inactive", mod.ModId);
+ var modFolder = Directory.Exists(activePath) ? activePath
+ : Directory.Exists(inactivePath) ? inactivePath
+ : null;
+ if (modFolder == null) continue;
+
+ foreach (var file in Directory.EnumerateFiles(modFolder, "*", SearchOption.AllDirectories))
+ {
+ var relative = Path.GetRelativePath(modFolder, file)
+ .Replace('\\', '/');
+ var entryName = $"mods/{mod.ModId}/{relative}";
+ archive.CreateEntryFromFile(file, entryName,
+ System.IO.Compression.CompressionLevel.Fastest);
+ }
+ }
+ });
+ return zipPath;
+ }
+
+ public Task GetUniqueProfileNameAsync(string name, string profilesPath)
+ {
+ var safeName = Path.GetFileNameWithoutExtension(ManagerStorage.ProfileFileName(name));
+ var candidate = safeName;
+ var counter = 2;
+ while (File.Exists(Path.Combine(profilesPath, ManagerStorage.ProfileFileName(candidate))))
+ {
+ candidate = $"{safeName} ({counter++})";
+ }
+ return Task.FromResult(candidate);
+ }
+
+ 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)>();
+ var toDownload = new List();
+ var toRedownload = new List();
+
+ foreach (var entry in profile.Mods)
+ {
+ if (!currentById.TryGetValue(entry.ModId, out var current))
+ {
+ // Only download mods that should be active in this profile
+ if (entry.IsActive &&
+ (!string.IsNullOrEmpty(entry.RepositoryUrl) || !string.IsNullOrEmpty(entry.HomePageUrl)))
+ toDownload.Add(entry);
+ 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)
+ {
+ // If a repository URL is available, prefer re-downloading the correct version
+ // over a local rollback (which may not have the right version cached)
+ if (!string.IsNullOrEmpty(entry.RepositoryUrl))
+ toRedownload.Add(entry);
+ else
+ 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, toDownload, toRedownload);
+ }
+
+ private static string GetProfileFilePath(string name, string profilesPath) =>
+ Path.Combine(profilesPath, ManagerStorage.ProfileFileName(name));
+}
diff --git a/DVModManager/Services/SettingsService.cs b/DVModManager/Services/SettingsService.cs
new file mode 100644
index 0000000..a8bb82a
--- /dev/null
+++ b/DVModManager/Services/SettingsService.cs
@@ -0,0 +1,49 @@
+using System.Text.Json;
+using DVModManager.Models;
+
+namespace DVModManager.Services;
+
+public class SettingsService : ISettingsService
+{
+ private static string SettingsFilePath => ManagerStorage.SettingsFilePath;
+
+ 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();
+
+ // Migrate legacy CollapsedGroupIds to per-panel sets
+ if (Settings.CollapsedGroupIds is { Count: > 0 })
+ {
+ if (Settings.CollapsedGroupIdsAvailable.Count == 0)
+ Settings.CollapsedGroupIdsAvailable = new HashSet(Settings.CollapsedGroupIds);
+ if (Settings.CollapsedGroupIdsActive.Count == 0)
+ Settings.CollapsedGroupIdsActive = new HashSet(Settings.CollapsedGroupIds);
+ Settings.CollapsedGroupIds = null; // clear legacy field
+ }
+ }
+ 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..5a31c32
--- /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 = "modversion.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..6d8d18c
--- /dev/null
+++ b/DVModManager/ViewModels/MainWindowViewModel.cs
@@ -0,0 +1,1849 @@
+using System.Collections.ObjectModel;
+using System.Diagnostics;
+using System.Reflection;
+using System.Text.Json;
+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;
+ private readonly ILocalizationService _localization;
+
+ // ── Observable state ──────────────────────────────────────────────────────
+ [ObservableProperty] private ObservableCollection _availableMods = [];
+ [ObservableProperty] private ObservableCollection _activeMods = [];
+ [ObservableProperty] private ModItemViewModel? _selectedMod;
+ [ObservableProperty] private ModGroupHeaderViewModel? _selectedGroup;
+ [ObservableProperty] private bool _isGameRunning;
+ [ObservableProperty] private string _statusMessage = "";
+ [ObservableProperty] private bool _isBusy;
+ [ObservableProperty] private string _busyMessage = "";
+ [ObservableProperty] private string _selectedProfileName = ManagerStorage.DefaultProfileName;
+ [ObservableProperty] private ObservableCollection _profileNames = [];
+ [ObservableProperty] private string _windowTitle = "DV Mod Manager";
+ [ObservableProperty] private string _panelAvailableHeader = "Available Mods";
+ [ObservableProperty] private string _panelActiveHeader = "Active Mods";
+
+ // ── App version / update ───────────────────────────────────────────────────
+ private const string ManagerUpdateRepository = "https://raw.githubusercontent.com/Fuggschen/DVModManager/main/DVModManager/repository.json";
+
+ public string AppVersion { get; } = StripMetadata(
+ Assembly.GetExecutingAssembly()
+ .GetCustomAttribute()?.InformationalVersion
+ ?? Assembly.GetExecutingAssembly().GetName().Version?.ToString()
+ ?? "0.0.0");
+
+ private static string StripMetadata(string version)
+ {
+ var plus = version.IndexOf('+');
+ return plus > 0 ? version[..plus] : version;
+ }
+
+ [ObservableProperty] private bool _hasManagerUpdate;
+ [ObservableProperty] private string _latestManagerVersion = "";
+ [ObservableProperty] private string _managerUpdateUrl = "";
+ [ObservableProperty] private string _appVersionLabel = "";
+
+ // ── Companion mod state ───────────────────────────────────────────────────
+ private const string CompanionModId = "DVModProfiles";
+ private const string CompanionModRepository = "https://raw.githubusercontent.com/Fuggschen/DVModManager/main/DVModProfiles/repository.json";
+
+ [ObservableProperty] private bool _isCompanionModInstalled;
+ [ObservableProperty] private bool _isCompanionModInactive;
+ [ObservableProperty] private string _companionButtonKey = "button.install_companion";
+
+ /// Localized text for the companion button (computed from CompanionButtonKey).
+ public string CompanionButtonContent => _localization.GetString(CompanionButtonKey);
+
+ partial void OnCompanionButtonKeyChanged(string value)
+ {
+ OnPropertyChanged(nameof(CompanionButtonContent));
+ }
+
+ /// Checks whether the companion mod exists in either available or active mods.
+ private void UpdateCompanionModState()
+ {
+ var companion = AvailableMods.Concat(ActiveMods)
+ .FirstOrDefault(m => string.Equals(m.Id, CompanionModId, StringComparison.OrdinalIgnoreCase));
+ IsCompanionModInstalled = companion != null;
+ IsCompanionModInactive = companion != null && !companion.IsActive;
+ CompanionButtonKey = companion switch
+ {
+ null => "button.install_companion",
+ { IsActive: true } => "button.activated_companion",
+ _ => "button.activate_companion"
+ };
+ InstallCompanionModCommand.NotifyCanExecuteChanged();
+ }
+
+ // ── 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 = "";
+ // Mixed list: ModGroupHeaderViewModel | ModItemViewModel
+ [ObservableProperty] private ObservableCollection