diff --git a/.agents/skills/dotnet-patterns/SKILL.md b/.agents/skills/dotnet-patterns/SKILL.md new file mode 100644 index 00000000..b54fb766 --- /dev/null +++ b/.agents/skills/dotnet-patterns/SKILL.md @@ -0,0 +1,321 @@ +--- +name: dotnet-patterns +description: Idiomatic C# and .NET patterns, conventions, dependency injection, async/await, and best practices for building robust, maintainable .NET applications. +origin: ECC +--- + +# .NET Development Patterns + +Idiomatic C# and .NET patterns for building robust, performant, and maintainable applications. + +## When to Activate + +- Writing new C# code +- Reviewing C# code +- Refactoring existing .NET applications +- Designing service architectures with ASP.NET Core + +## Core Principles + +### 1. Prefer Immutability + +Use records and init-only properties for data models. Mutability should be an explicit, justified choice. + +```csharp +// Good: Immutable value object +public sealed record Money(decimal Amount, string Currency); + +// Good: Immutable DTO with init setters +public sealed class CreateOrderRequest +{ + public required string CustomerId { get; init; } + public required IReadOnlyList Items { get; init; } +} + +// Bad: Mutable model with public setters +public class Order +{ + public string CustomerId { get; set; } + public List Items { get; set; } +} +``` + +### 2. Explicit Over Implicit + +Be clear about nullability, access modifiers, and intent. + +```csharp +// Good: Explicit access modifiers and nullability +public sealed class UserService +{ + private readonly IUserRepository _repository; + private readonly ILogger _logger; + + public UserService(IUserRepository repository, ILogger logger) + { + _repository = repository ?? throw new ArgumentNullException(nameof(repository)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken) + { + return await _repository.FindByIdAsync(id, cancellationToken); + } +} +``` + +### 3. Depend on Abstractions + +Use interfaces for service boundaries. Register via DI container. + +```csharp +// Good: Interface-based dependency +public interface IOrderRepository +{ + Task FindByIdAsync(Guid id, CancellationToken cancellationToken); + Task> FindByCustomerAsync(string customerId, CancellationToken cancellationToken); + Task AddAsync(Order order, CancellationToken cancellationToken); +} + +// Registration +builder.Services.AddScoped(); +``` + +## Async/Await Patterns + +### Proper Async Usage + +```csharp +// Good: Async all the way, with CancellationToken +public async Task GetOrderSummaryAsync( + Guid orderId, + CancellationToken cancellationToken) +{ + var order = await _repository.FindByIdAsync(orderId, cancellationToken) + ?? throw new NotFoundException($"Order {orderId} not found"); + + var customer = await _customerService.GetAsync(order.CustomerId, cancellationToken); + + return new OrderSummary(order, customer); +} + +// Bad: Blocking on async +public OrderSummary GetOrderSummary(Guid orderId) +{ + var order = _repository.FindByIdAsync(orderId, CancellationToken.None).Result; // Deadlock risk + return new OrderSummary(order); +} +``` + +### Parallel Async Operations + +```csharp +// Good: Concurrent independent operations +public async Task LoadDashboardAsync(CancellationToken cancellationToken) +{ + var ordersTask = _orderService.GetRecentAsync(cancellationToken); + var metricsTask = _metricsService.GetCurrentAsync(cancellationToken); + var alertsTask = _alertService.GetActiveAsync(cancellationToken); + + await Task.WhenAll(ordersTask, metricsTask, alertsTask); + + return new DashboardData( + Orders: await ordersTask, + Metrics: await metricsTask, + Alerts: await alertsTask); +} +``` + +## Options Pattern + +Bind configuration sections to strongly-typed objects. + +```csharp +public sealed class SmtpOptions +{ + public const string SectionName = "Smtp"; + + public required string Host { get; init; } + public required int Port { get; init; } + public required string Username { get; init; } + public bool UseSsl { get; init; } = true; +} + +// Registration +builder.Services.Configure( + builder.Configuration.GetSection(SmtpOptions.SectionName)); + +// Usage via injection +public class EmailService(IOptions options) +{ + private readonly SmtpOptions _smtp = options.Value; +} +``` + +## Result Pattern + +Return explicit success/failure instead of throwing for expected failures. + +```csharp +public sealed record Result +{ + public bool IsSuccess { get; } + public T? Value { get; } + public string? Error { get; } + + private Result(T value) { IsSuccess = true; Value = value; } + private Result(string error) { IsSuccess = false; Error = error; } + + public static Result Success(T value) => new(value); + public static Result Failure(string error) => new(error); +} + +// Usage +public async Task> PlaceOrderAsync(CreateOrderRequest request) +{ + if (request.Items.Count == 0) + return Result.Failure("Order must contain at least one item"); + + var order = Order.Create(request); + await _repository.AddAsync(order, CancellationToken.None); + return Result.Success(order); +} +``` + +## Repository Pattern with EF Core + +```csharp +public sealed class SqlOrderRepository : IOrderRepository +{ + private readonly AppDbContext _db; + + public SqlOrderRepository(AppDbContext db) => _db = db; + + public async Task FindByIdAsync(Guid id, CancellationToken cancellationToken) + { + return await _db.Orders + .Include(o => o.Items) + .AsNoTracking() + .FirstOrDefaultAsync(o => o.Id == id, cancellationToken); + } + + public async Task> FindByCustomerAsync( + string customerId, + CancellationToken cancellationToken) + { + return await _db.Orders + .Where(o => o.CustomerId == customerId) + .OrderByDescending(o => o.CreatedAt) + .AsNoTracking() + .ToListAsync(cancellationToken); + } + + public async Task AddAsync(Order order, CancellationToken cancellationToken) + { + _db.Orders.Add(order); + await _db.SaveChangesAsync(cancellationToken); + } +} +``` + +## Middleware and Pipeline + +```csharp +// Custom middleware +public sealed class RequestTimingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public RequestTimingMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + var stopwatch = Stopwatch.StartNew(); + try + { + await _next(context); + } + finally + { + stopwatch.Stop(); + _logger.LogInformation( + "Request {Method} {Path} completed in {ElapsedMs}ms with status {StatusCode}", + context.Request.Method, + context.Request.Path, + stopwatch.ElapsedMilliseconds, + context.Response.StatusCode); + } + } +} +``` + +## Minimal API Patterns + +```csharp +// Organized with route groups +var orders = app.MapGroup("/api/orders") + .RequireAuthorization() + .WithTags("Orders"); + +orders.MapGet("/{id:guid}", async ( + Guid id, + IOrderRepository repository, + CancellationToken cancellationToken) => +{ + var order = await repository.FindByIdAsync(id, cancellationToken); + return order is not null + ? TypedResults.Ok(order) + : TypedResults.NotFound(); +}); + +orders.MapPost("/", async ( + CreateOrderRequest request, + IOrderService service, + CancellationToken cancellationToken) => +{ + var result = await service.PlaceOrderAsync(request, cancellationToken); + return result.IsSuccess + ? TypedResults.Created($"/api/orders/{result.Value!.Id}", result.Value) + : TypedResults.BadRequest(result.Error); +}); +``` + +## Guard Clauses + +```csharp +// Good: Early returns with clear validation +public async Task ProcessPaymentAsync( + PaymentRequest request, + CancellationToken cancellationToken) +{ + ArgumentNullException.ThrowIfNull(request); + + if (request.Amount <= 0) + throw new ArgumentOutOfRangeException(nameof(request.Amount), "Amount must be positive"); + + if (string.IsNullOrWhiteSpace(request.Currency)) + throw new ArgumentException("Currency is required", nameof(request.Currency)); + + // Happy path continues here without nesting + var gateway = _gatewayFactory.Create(request.Currency); + return await gateway.ChargeAsync(request, cancellationToken); +} +``` + +## Anti-Patterns to Avoid + +| Anti-Pattern | Fix | +|---|---| +| `async void` methods | Return `Task` (except event handlers) | +| `.Result` or `.Wait()` | Use `await` | +| `catch (Exception) { }` | Handle or rethrow with context | +| `new Service()` in constructors | Use constructor injection | +| `public` fields | Use properties with appropriate accessors | +| `dynamic` in business logic | Use generics or explicit types | +| Mutable `static` state | Use DI scoping or `ConcurrentDictionary` | +| `string.Format` in loops | Use `StringBuilder` or interpolated string handlers | diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 00000000..4082aed8 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-reportgenerator-globaltool": { + "version": "5.5.10", + "commands": [ + "reportgenerator" + ], + "rollForward": false + } + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..57282c5f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,164 @@ +root = true + +[*] +charset = utf-8 +end_of_line = crlf +insert_final_newline = true +trim_trailing_whitespace = true +indent_style = space +indent_size = 4 + +[*.{csproj,props,targets,slnx}] +indent_size = 2 + +[*.cs] +indent_size = 4 + +# Baseline C# style. Keep legacy code mostly advisory while the refactor is in +# progress; stricter severities are applied to the new GenLauncherGO.* projects +# below. +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false + +csharp_style_namespace_declarations = file_scoped:suggestion +csharp_style_prefer_method_group_conversion = true:suggestion +csharp_style_prefer_primary_constructors = false:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_style_prefer_switch_expression = true:suggestion +csharp_style_prefer_pattern_matching = true:suggestion +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_extended_property_pattern = true:suggestion +csharp_style_prefer_readonly_struct = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion +csharp_style_expression_bodied_methods = false:suggestion +csharp_style_expression_bodied_constructors = false:suggestion +csharp_style_expression_bodied_operators = false:suggestion +csharp_style_expression_bodied_properties = true:suggestion +csharp_style_expression_bodied_indexers = true:suggestion +csharp_style_expression_bodied_accessors = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_conditional_delegate_call = true:suggestion +csharp_prefer_braces = true:suggestion +csharp_prefer_simple_using_statement = true:suggestion +csharp_prefer_static_local_function = true:suggestion + +csharp_style_var_for_built_in_types = false:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = false:suggestion + +dotnet_style_qualification_for_field = false:suggestion +dotnet_style_qualification_for_property = false:suggestion +dotnet_style_qualification_for_method = false:suggestion +dotnet_style_qualification_for_event = false:suggestion +dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion +dotnet_style_predefined_type_for_member_access = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = false:suggestion +dotnet_style_prefer_conditional_expression_over_return = false:suggestion +dotnet_style_readonly_field = true:suggestion +dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggestion + +# Naming rules. +dotnet_naming_rule.types_are_pascal_case.symbols = types +dotnet_naming_rule.types_are_pascal_case.style = pascal_case +dotnet_naming_rule.types_are_pascal_case.severity = suggestion +dotnet_naming_symbols.types.applicable_kinds = class, struct, enum, delegate +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_rule.interfaces_start_with_i.symbols = interfaces +dotnet_naming_rule.interfaces_start_with_i.style = prefix_interface_with_i +dotnet_naming_rule.interfaces_start_with_i.severity = suggestion +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_style.prefix_interface_with_i.required_prefix = I +dotnet_naming_style.prefix_interface_with_i.capitalization = pascal_case + +dotnet_naming_rule.members_are_pascal_case.symbols = members +dotnet_naming_rule.members_are_pascal_case.style = pascal_case +dotnet_naming_rule.members_are_pascal_case.severity = suggestion +dotnet_naming_symbols.members.applicable_kinds = property, method, event + +dotnet_naming_rule.non_private_fields_are_pascal_case.symbols = non_private_fields +dotnet_naming_rule.non_private_fields_are_pascal_case.style = pascal_case +dotnet_naming_rule.non_private_fields_are_pascal_case.severity = suggestion +dotnet_naming_symbols.non_private_fields.applicable_kinds = field +dotnet_naming_symbols.non_private_fields.applicable_accessibilities = public, internal, protected, protected_internal, private_protected + +dotnet_naming_rule.private_fields_are_camel_case.symbols = private_fields +dotnet_naming_rule.private_fields_are_camel_case.style = underscore_camel_case +dotnet_naming_rule.private_fields_are_camel_case.severity = suggestion +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private +dotnet_naming_style.underscore_camel_case.required_prefix = _ +dotnet_naming_style.underscore_camel_case.capitalization = camel_case + +dotnet_naming_rule.constants_are_pascal_case.symbols = constants +dotnet_naming_rule.constants_are_pascal_case.style = pascal_case +dotnet_naming_rule.constants_are_pascal_case.severity = suggestion +dotnet_naming_symbols.constants.applicable_kinds = field +dotnet_naming_symbols.constants.required_modifiers = const + +dotnet_naming_rule.async_methods_end_in_async.symbols = async_methods +dotnet_naming_rule.async_methods_end_in_async.style = suffix_async +dotnet_naming_rule.async_methods_end_in_async.severity = suggestion +dotnet_naming_symbols.async_methods.applicable_kinds = method +dotnet_naming_symbols.async_methods.required_modifiers = async +dotnet_naming_style.suffix_async.required_suffix = Async +dotnet_naming_style.suffix_async.capitalization = pascal_case + +dotnet_naming_rule.parameters_are_camel_case.symbols = parameters +dotnet_naming_rule.parameters_are_camel_case.style = camel_case +dotnet_naming_rule.parameters_are_camel_case.severity = suggestion +dotnet_naming_symbols.parameters.applicable_kinds = parameter +dotnet_naming_style.camel_case.capitalization = camel_case + +dotnet_naming_rule.type_parameters_start_with_t.symbols = type_parameters +dotnet_naming_rule.type_parameters_start_with_t.style = prefix_type_parameter_with_t +dotnet_naming_rule.type_parameters_start_with_t.severity = suggestion +dotnet_naming_symbols.type_parameters.applicable_kinds = type_parameter +dotnet_naming_style.prefix_type_parameter_with_t.required_prefix = T +dotnet_naming_style.prefix_type_parameter_with_t.capitalization = pascal_case + +# New architecture code should follow the conventions as build-enforced errors +# from day one. Legacy GenLauncherNet code remains advisory while migration is +# in progress. +[GenLauncherGO.*/**.cs] +csharp_style_namespace_declarations = file_scoped:warning +csharp_prefer_braces = true:warning +csharp_style_var_for_built_in_types = false:warning +csharp_style_var_when_type_is_apparent = true:warning +csharp_style_var_elsewhere = false:warning +dotnet_style_readonly_field = true:warning +dotnet_style_require_accessibility_modifiers = for_non_interface_members:warning +dotnet_naming_rule.types_are_pascal_case.severity = error +dotnet_naming_rule.interfaces_start_with_i.severity = error +dotnet_naming_rule.members_are_pascal_case.severity = error +dotnet_naming_rule.non_private_fields_are_pascal_case.severity = error +dotnet_naming_rule.private_fields_are_camel_case.severity = error +dotnet_naming_rule.constants_are_pascal_case.severity = error +dotnet_naming_rule.async_methods_end_in_async.severity = error +dotnet_naming_rule.parameters_are_camel_case.severity = error +dotnet_naming_rule.type_parameters_start_with_t.severity = error +dotnet_diagnostic.IDE1006.severity = error +dotnet_diagnostic.IDE0130.severity = warning + +# All production types and members in the new GenLauncherGO.* projects must have +# XML documentation. CS1591 enforces externally visible APIs today; internal and +# private member documentation remains a manual review requirement until a +# repository analyzer enforces it. +dotnet_diagnostic.CS1591.severity = warning +dotnet_diagnostic.CS1570.severity = warning +dotnet_diagnostic.CS1572.severity = warning +dotnet_diagnostic.CS1573.severity = warning + +[*.xaml] +indent_size = 4 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 4d4f0dc8..00000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: Bug report -about: Report a bug with GenLauncher. -title: '' -labels: '' -assignees: '' - ---- - -**Describe the bug** -A clear and concise description of what the bug is. - -**To Reproduce** -Steps to reproduce the behavior: - -**Expected behavior** -A clear and concise description of what you expected to happen. - -**Screenshots** -If applicable, add screenshots to help explain your problem. - -**Desktop (please complete the following information):** - - Operating System - - GenLauncher Version - - Game being managed (Generals or Zero Hour) - -**Additional context** -Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md deleted file mode 100644 index 9adf2b42..00000000 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: Feature request -about: Suggest a feature or enhancement to GenLauncher. -title: '' -labels: '' -assignees: '' - ---- - -**Is your feature request related to a problem? Please describe.** -A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] - -**Describe the solution you'd like** -A clear and concise description of what you want to happen. - -**Describe alternatives you've considered** -A clear and concise description of any alternative solutions or features you've considered. - -**Additional context** -Add any other context or screenshots about the feature request here. diff --git a/.gitignore b/.gitignore index 4f3be99f..106fbbc2 100644 --- a/.gitignore +++ b/.gitignore @@ -395,4 +395,5 @@ FodyWeavers.xsd *.msp # JetBrains Rider -*.sln.iml \ No newline at end of file +*.sln.iml +.idea \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..5424db1b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,113 @@ +# GenLauncherGO Agent Guidelines + +GenLauncherGO is a Windows WPF mod management utility and launcher for Command & Conquer: Generals and Zero Hour community clients. + +## Required Commands + +- Restore/build: `dotnet build GenLauncherGO.sln` +- Run tests when behavior or test files change: `dotnet test GenLauncherGO.sln` +- Prefer the narrowest useful verification command, but report clearly if a command could not be run. + +## Repository Facts + +- The solution is `GenLauncherGO.sln`. +- The executable WPF application, localization resources, and composition code live in `GenLauncherGO.UI/`. +- The active architecture projects are `GenLauncherGO.Core/`, `GenLauncherGO.Infrastructure/`, `GenLauncherGO.UI/`, and `GenLauncherGO.Tests/`. +- There is intentionally no `src/` folder. +- Package versions are centralized in `Directory.Packages.props`; project files should use versionless `PackageReference` entries. + +## Always Follow + +- Use `GenLauncherGO` for new user-facing code, documentation, projects, and solution-level names. +- Do not introduce standalone `GenLauncher` names; keep existing `GenLauncherGO` project, assembly, namespace, and file-system names unless the task is explicitly a coordinated rename. +- Keep file-system changes conservative. Launch preparation may touch a user's game folder through symbolic links and file renames. +- Preserve existing behavior unless the user explicitly requests a behavior change. +- Preserve user or local dirty work. Do not revert changes you did not make unless the user explicitly asks. +- Keep unrelated cleanup out of feature work, bug fixes, and maintenance work. +- Follow `.editorconfig`, `Directory.Build.props`, and central package management. +- Prefer file-scoped namespaces, braces for control flow, explicit types unless obvious, `_camelCase` fields, `PascalCase` members, `I` interfaces, and `Async` suffixes. +- Prefer constructor injection over static/global access or service locators. +- Prefer `internal` or `private` until a cross-project contract is intentional. +- Prefer sealed concrete classes unless inheritance is intentional. +- Keep one primary type per file and name the file after that type. +- Avoid catch-all names such as `DataHandler`, `Helper`, `Manager`, or `Utility` for new code. +- Add analyzer suppressions only when there is a clear reason and include a short justification. + +## Required Nested Guidance + +Before editing files under one of these directories, read and follow the nearest nested `AGENTS.md`: + +| Path | Focus | +| --- | --- | +| `GenLauncherGO.Core/` | Domain contracts, models, and side-effect-free logic | +| `GenLauncherGO.Infrastructure/` | File-system, process, network, archive, persistence, and logging adapters | +| `GenLauncherGO.UI/` | WPF executable, presentation, composition root, localization, and UI resources | +| `GenLauncherGO.Tests/` | xUnit test organization and test isolation | + +If a task spans multiple areas, read each relevant nested `AGENTS.md` before editing. + +## Decision Rules + +| Question | Do | +| --- | --- | +| Adding or moving domain logic? | Put dependency-light models, contracts, and orchestration abstractions in `GenLauncherGO.Core/`. | +| Touching disk, network, archives, processes, symbolic links, hashing, or persistence? | Put concrete implementation in `GenLauncherGO.Infrastructure/` behind Core contracts. | +| Adding WPF views, commands, view models, resources, startup composition, executable metadata, or localization behavior? | Put presentation and executable application code in `GenLauncherGO.UI/`. | +| Adding UI text? | Update every `GenLauncherGO.UI/Resources/Strings*.resx` localization resource in the same change with locale-specific text; do not use English fallback copies unless the user explicitly asks for them. | +| Adding packages? | Add or update versions only in `Directory.Packages.props`; keep `PackageReference` entries versionless. | +| Adding tests? | Use xUnit, FluentAssertions, and NSubstitute unless a specific limitation requires otherwise. | + +## Architecture Boundaries + +- `GenLauncherGO.UI` may reference `GenLauncherGO.Core` and `GenLauncherGO.Infrastructure`. +- `GenLauncherGO.Infrastructure` may reference `GenLauncherGO.Core`. +- `GenLauncherGO.Core` must not reference WPF, Infrastructure, UI resources, Windows-specific adapters, or third-party implementation packages. +- `GenLauncherGO.Tests` may reference projects under test. +- Use `GenLauncherGO.UI` as the composition root for dependency injection. +- Do not add mediator, CQRS, or broad service-locator frameworks unless a concrete feature requires them. +- Prefer `ILauncherContentCatalogLoader`, `ILauncherContentCatalogQueries`, and + `ILauncherContentCatalogCommands` for new catalog consumers. Keep `ILauncherContentCatalogService` as a compatibility + aggregate only. +- Do not rename or remove legacy catalog YAML/schema members such as `modDatas`, `originalGameAddons`, or + `originalGamePatches` without a deliberate data/schema compatibility plan. + +## Feature Folder Layering + +- Organize production code by feature/domain first, such as `Updating`, `Launching`, `Mods`, `Settings`, or `Startup`. +- Keep a feature folder flat only while it has one responsibility and a small number of files. +- When a feature contains mixed responsibilities or grows beyond roughly 6 production files, you must add layer folders + inside that feature instead of leaving all files flat. +- Put boundary interfaces and cross-project contracts in `Contracts/`, request/result/value-object/domain data in + `Models/`, concrete workflow implementations in `Services/`, dependency injection registration in `Composition/`, + and narrow adapter helpers in `Support/`. +- Example: use `Launching/Contracts/IDeploymentService.cs`, `Launching/Models/DeploymentRequest.cs`, + `Launching/Services/FileSystemDeploymentService.cs`, and `Launching/Composition/DeploymentServiceCollectionExtensions.cs`. +- Do not create repository-wide type folders such as top-level `Models/`, `Services/`, or `Contracts/`. +- Use subfolders that describe real responsibilities in that feature, such as `Contracts`, `Models`, `Services`, `Clients`, `Options`, `Composition`, `Validation`, or `Support`. +- Keep namespaces aligned with folders when files move into feature subfolders. +- Do not create empty placeholder folders or deep folder nesting before the feature needs it. + +## Documentation And Comments + +- Add XML documentation for every production type and member in `GenLauncherGO.Core`, `GenLauncherGO.Infrastructure`, and `GenLauncherGO.UI`, regardless of accessibility. +- This includes public, protected, internal, private, nested, and helper members unless the file is generated code. +- Keep existing XML documentation current when behavior changes. +- Document side effects in Infrastructure XML docs. +- `CS1591` only enforces externally visible API docs today; agents must manually enforce internal and private XML documentation. +- Do not add comments for obvious code; add short comments only for non-obvious logic, platform quirks, workarounds, or decisions that prevent accidental simplification. + +## Logging + +- Logging is a required design concern for Infrastructure and UI code with meaningful side effects. +- `GenLauncherGO.Core` should not depend on logging by default; return typed results/errors for expected failures. +- `GenLauncherGO.Infrastructure` and `GenLauncherGO.UI` should use `ILogger` for diagnostics around file-system, process, network, archive, persistence, update, launch, and user-flow failures. +- Do not silently swallow exceptions. Log with useful context or convert to a typed result that preserves diagnostic detail. +- Current build settings do not mechanically prove that every required path logs; agents must enforce this during implementation and review. + +## Git Workflow + +- Use Conventional Commits when creating commits: `type(scope): short imperative summary`. +- Include a scope whenever the affected area is clear. +- Omit the scope only when the commit is truly repository-wide or no concise scope fits. +- Derive the scope from the changed project, architectural boundary, feature, or workflow instead of using a hardcoded scope list. +- Common types: `feat`, `fix`, `refactor`, `test`, `docs`, `build`, `ci`, and `chore`. diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 00000000..6eff2ad6 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,20 @@ + + + 14.0 + enable + disable + latest + true + false + true + + + + true + $(WarningsAsErrors);CS1591 + + + + $(WarningsAsErrors);IDE1006 + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 00000000..cdf48be1 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,31 @@ + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GenLauncher.sln b/GenLauncher.sln deleted file mode 100644 index 0a07d21f..00000000 --- a/GenLauncher.sln +++ /dev/null @@ -1,31 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.3.32901.215 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GenLauncher", "GenLauncherNet\GenLauncher.csproj", "{4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug for Generals|Any CPU = Debug for Generals|Any CPU - Debug for Zero Hour|Any CPU = Debug for Zero Hour|Any CPU - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}.Debug for Generals|Any CPU.ActiveCfg = Debug for Generals|Any CPU - {4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}.Debug for Generals|Any CPU.Build.0 = Debug for Generals|Any CPU - {4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}.Debug for Zero Hour|Any CPU.ActiveCfg = Debug for Zero Hour|Any CPU - {4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}.Debug for Zero Hour|Any CPU.Build.0 = Debug for Zero Hour|Any CPU - {4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4A8C2419-CE0F-405A-8D72-5CC6A6A3D1AB}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {D7CECCC4-7CE9-476B-9874-1B6F7FA506C0} - EndGlobalSection -EndGlobal diff --git a/GenLauncherGO.Core/AGENTS.md b/GenLauncherGO.Core/AGENTS.md new file mode 100644 index 00000000..d45859b8 --- /dev/null +++ b/GenLauncherGO.Core/AGENTS.md @@ -0,0 +1,31 @@ +# GenLauncherGO.Core Agent Guidelines + +`GenLauncherGO.Core/` owns dependency-light application models, contracts, validation, and orchestration abstractions. + +## Do + +- Keep Core independent of WPF, UI resources, Infrastructure, Windows APIs, disk, network, processes, S3, archive + packages, hashing implementations, and symbolic-link implementations. +- Define interfaces for infrastructure boundaries and test seams. +- Prefer immutable models for new Core data shapes. +- Prefer explicit result/failure models for expected failures, especially launch validation and update readiness. +- Pass `CancellationToken` through new async APIs. +- Add XML documentation for every production type and member, regardless of accessibility. +- Prefer feature folders such as `Common/`, `Archives/`, `Launching/`, `Mods/`, `Settings/`, `Startup/`, and + `Updating/`. +- Keep a Core feature folder flat only while it has one responsibility and a small number of files. +- When a Core feature contains both boundary contracts and domain data, or grows beyond roughly 6 production files, + split + it inside the feature: `Contracts/` for interfaces and cross-project boundary contracts, `Models/` for records, + enums, value objects, requests, results, and manifests, and `Validation/` or `Services/` only when those + responsibilities are substantial. +- Keep namespaces aligned with those layer folders, such as `GenLauncherGO.Core.Launching.Contracts` and + `GenLauncherGO.Core.Launching.Models`. + +## Avoid + +- Do not reference `GenLauncherGO.Infrastructure` or `GenLauncherGO.UI`. +- Do not expose third-party package names in Core contracts when a domain abstraction is possible. +- Do not add logging dependencies unless there is a clear design reason; prefer typed results/errors for expected + failures. +- Do not create interfaces for every class by default. Use them where they define a real boundary. diff --git a/GenLauncherGO.Core/Archives/ArchiveExtractionOptions.cs b/GenLauncherGO.Core/Archives/ArchiveExtractionOptions.cs new file mode 100644 index 00000000..a91b63c2 --- /dev/null +++ b/GenLauncherGO.Core/Archives/ArchiveExtractionOptions.cs @@ -0,0 +1,13 @@ +namespace GenLauncherGO.Core.Archives; + +/// +/// Defines options that control how archive entries are extracted. +/// +public sealed record ArchiveExtractionOptions +{ + /// + /// Gets a value indicating whether extracted archive entries ending in .big should be written as + /// .gib. + /// + public bool ConvertBigFilesToGib { get; init; } +} diff --git a/GenLauncherGO.Core/Archives/IArchiveExtractor.cs b/GenLauncherGO.Core/Archives/IArchiveExtractor.cs new file mode 100644 index 00000000..344e2838 --- /dev/null +++ b/GenLauncherGO.Core/Archives/IArchiveExtractor.cs @@ -0,0 +1,43 @@ +using System; +using System.IO; +using System.Threading; + +namespace GenLauncherGO.Core.Archives; + +/// +/// Extracts supported archive files into a destination directory. +/// +public interface IArchiveExtractor +{ + /// + /// Extracts an archive into the specified destination directory, creating directories and overwriting existing + /// extracted files when needed. + /// + /// The archive file to extract. + /// + /// The directory where archive entries should be written. Implementations must prevent archive entries from + /// escaping this directory. + /// + /// + /// Optional extraction behavior, including whether extracted .big files are renamed to .gib. + /// + /// A token that can cancel extraction between archive entries. + /// + /// Thrown when or is empty or + /// whitespace. + /// + /// + /// Thrown when the archive or destination files cannot be read or written. + /// + /// + /// Thrown when the archive is invalid or contains an entry that would extract outside the destination directory. + /// + /// + /// Thrown when the current process does not have access to read the archive or write extracted files. + /// + void ExtractToDirectory( + string archiveFilePath, + string destinationDirectory, + ArchiveExtractionOptions? options = null, + CancellationToken cancellationToken = default); +} diff --git a/GenLauncherGO.Core/GenLauncherGO.Core.csproj b/GenLauncherGO.Core/GenLauncherGO.Core.csproj new file mode 100644 index 00000000..dd220951 --- /dev/null +++ b/GenLauncherGO.Core/GenLauncherGO.Core.csproj @@ -0,0 +1,8 @@ + + + net10.0 + disable + enable + 14.0 + + diff --git a/GenLauncherGO.Core/Integrity/Contracts/IContentIntegrityService.cs b/GenLauncherGO.Core/Integrity/Contracts/IContentIntegrityService.cs new file mode 100644 index 00000000..92a865ba --- /dev/null +++ b/GenLauncherGO.Core/Integrity/Contracts/IContentIntegrityService.cs @@ -0,0 +1,70 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Core.Integrity.Contracts; + +/// +/// Verifies, snapshots, and cleans launcher-owned content. +/// +public interface IContentIntegrityService +{ + /// + /// Verifies all targets against their persisted trusted snapshots. + /// + /// The targets to verify. + /// A token that cancels verification. + /// A complete report containing every detected issue. + Task VerifyAsync( + IReadOnlyList targets, + CancellationToken cancellationToken); + + /// + /// Determines whether a target currently contains exactly the expected safe file set. + /// + /// The target to inspect without requiring a persisted snapshot. + /// The complete expected file paths relative to the target root. + /// A token that cancels enumeration or hashing. + /// + /// when the target has exactly the expected files and no empty directories, unsafe links, + /// or unreadable entries; otherwise, . + /// + Task MatchesExpectedFileSetAsync( + ContentIntegrityTarget target, + IReadOnlySet expectedRelativePaths, + CancellationToken cancellationToken); + + /// + /// Captures a trusted snapshot only when a target currently contains exactly the expected safe file set. + /// + /// The target to inspect and snapshot. + /// The complete expected file paths relative to the target root. + /// A token that cancels enumeration, hashing, or persistence. + /// + /// when a snapshot was captured; otherwise, when the current + /// file set contains extras, missing files, empty directories, unsafe links, or unreadable entries. + /// + Task CaptureSnapshotIfMatchesExpectedFileSetAsync( + ContentIntegrityTarget target, + IReadOnlySet expectedRelativePaths, + CancellationToken cancellationToken); + + /// + /// Replaces a target's trusted snapshot with its current safe directory contents. + /// + /// The target to snapshot. + /// A token that cancels hashing or persistence. + Task CaptureSnapshotAsync(ContentIntegrityTarget target, CancellationToken cancellationToken); + + /// + /// Deletes managed entries explicitly listed for deletion in a verification report. + /// + /// The report containing confirmed deletion actions. + /// The verified targets used to safely resolve relative paths. + /// A token that cancels cleanup between operations. + Task ApplyCleanupAsync( + ContentIntegrityReport report, + IReadOnlyList targets, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Integrity/Models/ContentIntegrityIssue.cs b/GenLauncherGO.Core/Integrity/Models/ContentIntegrityIssue.cs new file mode 100644 index 00000000..d1a32dbe --- /dev/null +++ b/GenLauncherGO.Core/Integrity/Models/ContentIntegrityIssue.cs @@ -0,0 +1,22 @@ +namespace GenLauncherGO.Core.Integrity.Models; + +/// +/// Describes one content-integrity issue and its available resolution. +/// +/// The stable identifier of the affected target. +/// The user-facing name of the affected content. +/// The authoritative source classification. +/// The detected issue kind. +/// The resolution offered for the issue. +/// The affected path relative to the verified target. +/// The diagnostic message for verification failures. +/// The expected file size when the trusted source provides it. +public sealed record ContentIntegrityIssue( + string TargetId, + string TargetDisplayName, + ContentSourceKind SourceKind, + IntegrityIssueKind Kind, + IntegrityIssueAction Action, + string RelativePath, + string? Message = null, + long? ExpectedSizeBytes = null); diff --git a/GenLauncherGO.Core/Integrity/Models/ContentIntegrityReport.cs b/GenLauncherGO.Core/Integrity/Models/ContentIntegrityReport.cs new file mode 100644 index 00000000..a38ff520 --- /dev/null +++ b/GenLauncherGO.Core/Integrity/Models/ContentIntegrityReport.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GenLauncherGO.Core.Integrity.Models; + +/// +/// Contains all issues found while verifying active launch content. +/// +public sealed record ContentIntegrityReport +{ + /// + /// Initializes a new instance of the record. + /// + /// All detected issues. + public ContentIntegrityReport(IReadOnlyList issues) + { + ArgumentNullException.ThrowIfNull(issues); + Issues = Array.AsReadOnly(issues.ToArray()); + } + + /// + /// Gets all detected issues. + /// + public IReadOnlyList Issues { get; } + + /// + /// Gets a value indicating whether any issue was detected. + /// + public bool HasIssues => Issues.Count > 0; + + /// + /// Gets a value indicating whether managed remote content requires resolution. + /// + public bool HasManagedIssues => Issues.Any(issue => + issue.SourceKind is ContentSourceKind.ManagedS3 or ContentSourceKind.ManagedSingleFile); + + /// + /// Gets a value indicating whether manually imported content has absorbable changes. + /// + public bool HasManualIssues => Issues.Any(issue => issue.Action == IntegrityIssueAction.Absorb); + + /// + /// Gets a value indicating whether unknown legacy content requires classification. + /// + public bool HasUnknownLegacyIssues => Issues.Any(issue => issue.Action == IntegrityIssueAction.TrustAsManual); + + /// + /// Gets a value indicating whether verification found an issue without an automatic resolution. + /// + public bool HasBlockingIssues => Issues.Any(issue => issue.Action == IntegrityIssueAction.Block); +} diff --git a/GenLauncherGO.Core/Integrity/Models/ContentIntegrityTarget.cs b/GenLauncherGO.Core/Integrity/Models/ContentIntegrityTarget.cs new file mode 100644 index 00000000..ddd0d48f --- /dev/null +++ b/GenLauncherGO.Core/Integrity/Models/ContentIntegrityTarget.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Frozen; +using System.Collections.Generic; + +namespace GenLauncherGO.Core.Integrity.Models; + +/// +/// Describes one launcher-owned directory that must be verified. +/// +public sealed record ContentIntegrityTarget +{ + /// + /// Initializes a new instance of the record. + /// + /// The stable identifier used for snapshot persistence. + /// The user-facing content name. + /// The directory to verify. + /// The authoritative source classification. + /// Known owned paths that belong to inactive content and must be preserved. + public ContentIntegrityTarget( + string id, + string displayName, + string rootDirectory, + ContentSourceKind sourceKind, + IReadOnlySet ignoredRelativePaths) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + ArgumentException.ThrowIfNullOrWhiteSpace(displayName); + ArgumentException.ThrowIfNullOrWhiteSpace(rootDirectory); + ArgumentNullException.ThrowIfNull(ignoredRelativePaths); + + Id = id; + DisplayName = displayName; + RootDirectory = rootDirectory; + SourceKind = sourceKind; + IgnoredRelativePaths = ignoredRelativePaths.ToFrozenSet(StringComparer.OrdinalIgnoreCase); + } + + /// + /// Gets the stable identifier used for snapshot persistence. + /// + public string Id { get; init; } + + /// + /// Gets the user-facing content name. + /// + public string DisplayName { get; init; } + + /// + /// Gets the directory to verify. + /// + public string RootDirectory { get; init; } + + /// + /// Gets the authoritative source classification. + /// + public ContentSourceKind SourceKind { get; init; } + + /// + /// Gets known owned paths that belong to inactive content and must be preserved without verification. + /// + public IReadOnlySet IgnoredRelativePaths { get; init; } +} diff --git a/GenLauncherGO.Core/Integrity/Models/ContentSourceKind.cs b/GenLauncherGO.Core/Integrity/Models/ContentSourceKind.cs new file mode 100644 index 00000000..cb864e5d --- /dev/null +++ b/GenLauncherGO.Core/Integrity/Models/ContentSourceKind.cs @@ -0,0 +1,27 @@ +namespace GenLauncherGO.Core.Integrity.Models; + +/// +/// Describes the authoritative source for installed launcher content. +/// +public enum ContentSourceKind +{ + /// + /// The source of the installed content has not yet been classified. + /// + UnknownLegacy, + + /// + /// The content is managed from an S3-compatible remote manifest. + /// + ManagedS3, + + /// + /// The content is managed from a remotely downloaded package file. + /// + ManagedSingleFile, + + /// + /// The content was manually imported or explicitly trusted by the user. + /// + Manual, +} diff --git a/GenLauncherGO.Core/Integrity/Models/IntegrityIssueAction.cs b/GenLauncherGO.Core/Integrity/Models/IntegrityIssueAction.cs new file mode 100644 index 00000000..7bcd139e --- /dev/null +++ b/GenLauncherGO.Core/Integrity/Models/IntegrityIssueAction.cs @@ -0,0 +1,37 @@ +namespace GenLauncherGO.Core.Integrity.Models; + +/// +/// Describes the resolution offered for an integrity issue. +/// +public enum IntegrityIssueAction +{ + /// + /// Launch remains blocked and no automatic resolution is available. + /// + Block, + + /// + /// The unexpected managed entry will be deleted. + /// + Delete, + + /// + /// The managed content will be repaired from its remote manifest. + /// + Repair, + + /// + /// The managed package will be downloaded and installed again. + /// + Redownload, + + /// + /// The current manual content will replace its trusted snapshot. + /// + Absorb, + + /// + /// The legacy content will be permanently classified and snapshotted as manual content. + /// + TrustAsManual, +} diff --git a/GenLauncherGO.Core/Integrity/Models/IntegrityIssueKind.cs b/GenLauncherGO.Core/Integrity/Models/IntegrityIssueKind.cs new file mode 100644 index 00000000..eaeb36e8 --- /dev/null +++ b/GenLauncherGO.Core/Integrity/Models/IntegrityIssueKind.cs @@ -0,0 +1,42 @@ +namespace GenLauncherGO.Core.Integrity.Models; + +/// +/// Describes a detected content-integrity problem. +/// +public enum IntegrityIssueKind +{ + /// + /// No trusted snapshot exists for the content. + /// + Untracked, + + /// + /// A required file is missing. + /// + MissingFile, + + /// + /// A file differs from its trusted SHA-256 snapshot. + /// + ModifiedFile, + + /// + /// A file is present but is not part of the trusted snapshot. + /// + UnexpectedFile, + + /// + /// An unexpected empty directory is present. + /// + EmptyDirectory, + + /// + /// A reparse point or symbolic link was found inside verified content. + /// + UnsafeLink, + + /// + /// Verification could not complete for an entry. + /// + VerificationError, +} diff --git a/GenLauncherGO.Core/Launching/Contracts/IDeploymentService.cs b/GenLauncherGO.Core/Launching/Contracts/IDeploymentService.cs new file mode 100644 index 00000000..aa496cea --- /dev/null +++ b/GenLauncherGO.Core/Launching/Contracts/IDeploymentService.cs @@ -0,0 +1,41 @@ +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Models; + +namespace GenLauncherGO.Core.Launching.Contracts; + +/// +/// Prepares, cleans, and recovers temporary game-directory deployments. +/// +public interface IDeploymentService +{ + /// + /// Prepares the game directory for a launch by deploying the selected packages. + /// + /// The deployment request. + /// A token that cancels deployment work. + /// The deployment result. + Task PrepareAsync( + DeploymentRequest request, + CancellationToken cancellationToken); + + /// + /// Cleans the active deployment from the game directory. + /// + /// The cleanup request. + /// A token that cancels cleanup work. + /// The cleanup result. + Task CleanupAsync( + DeploymentCleanupRequest request, + CancellationToken cancellationToken); + + /// + /// Recovers interrupted deployment work from the persisted manifest or journal. + /// + /// The recovery request. + /// A token that cancels recovery work. + /// The recovery result. + Task RecoverAsync( + DeploymentRecoveryRequest request, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Launching/Contracts/IGameExecutableDiscoveryService.cs b/GenLauncherGO.Core/Launching/Contracts/IGameExecutableDiscoveryService.cs new file mode 100644 index 00000000..510ccf86 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Contracts/IGameExecutableDiscoveryService.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Contracts; + +/// +/// Discovers game and World Builder executables available to the current launcher session. +/// +public interface IGameExecutableDiscoveryService +{ + /// + /// Gets the available game client executables for the managed game variant. + /// + /// The game variant managed by the current launcher session. + /// The ordered available game client executables. + IReadOnlyList GetAvailableGameClients(SupportedGame managedGame); + + /// + /// Gets the available World Builder executables for the managed game variant. + /// + /// The game variant managed by the current launcher session. + /// The ordered available World Builder executables. + IReadOnlyList GetAvailableWorldBuilders(SupportedGame managedGame); + + /// + /// Determines whether the executable path or name is currently available. + /// + /// The executable path or name to inspect. + /// when the executable exists; otherwise, . + bool IsExecutableAvailable(string? executableName); +} diff --git a/GenLauncherGO.Core/Launching/Contracts/IGameProcessLaunchOperation.cs b/GenLauncherGO.Core/Launching/Contracts/IGameProcessLaunchOperation.cs new file mode 100644 index 00000000..5c6dffa9 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Contracts/IGameProcessLaunchOperation.cs @@ -0,0 +1,36 @@ +using System; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Models; + +namespace GenLauncherGO.Core.Launching.Contracts; + +/// +/// Represents a launched game or tool process family that can be observed and force closed. +/// +public interface IGameProcessLaunchOperation +{ + /// + /// Gets the executable name used to start the tracked process family. + /// + string ExecutableName { get; } + + /// + /// Gets the executable name for the currently running tracked process. + /// + string CurrentExecutableName { get; } + + /// + /// Occurs when changes. + /// + event EventHandler? CurrentExecutableNameChanged; + + /// + /// Gets the task that completes when every tracked process in the launched process family has exited. + /// + Task Completion { get; } + + /// + /// Force closes the tracked process family. + /// + void ForceClose(); +} diff --git a/GenLauncherGO.Core/Launching/Contracts/IGameProcessLauncher.cs b/GenLauncherGO.Core/Launching/Contracts/IGameProcessLauncher.cs new file mode 100644 index 00000000..2f69f139 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Contracts/IGameProcessLauncher.cs @@ -0,0 +1,46 @@ +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Contracts; + +/// +/// Launches supported game and tool processes for a prepared game directory. +/// +public interface IGameProcessLauncher +{ + /// + /// Gets the community game executable name for the managed game variant. + /// + /// The game variant managed by the current launcher session. + /// The community game executable name. + string GetCommunityGameExecutableName(SupportedGame managedGame); + + /// + /// Gets the community World Builder executable name for the managed game variant. + /// + /// The game variant managed by the current launcher session. + /// The community World Builder executable name. + string GetCommunityWorldBuilderExecutableName(SupportedGame managedGame); + + /// + /// Starts the requested game or tool process and returns an operation that tracks its process family. + /// + /// The game launch request. + /// A token that cancels the launch start operation. + /// The tracked process launch operation. + Task StartAsync( + GameLaunchRequest request, + CancellationToken cancellationToken); + + /// + /// Launches the requested game or tool process and waits for its process family to exit. + /// + /// The game launch request. + /// A token that cancels the wait operation. + /// The game launch result. + Task LaunchAsync( + GameLaunchRequest request, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Launching/Contracts/ILaunchContentIntegrityResolutionService.cs b/GenLauncherGO.Core/Launching/Contracts/ILaunchContentIntegrityResolutionService.cs new file mode 100644 index 00000000..3f0acf86 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Contracts/ILaunchContentIntegrityResolutionService.cs @@ -0,0 +1,70 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Models; + +namespace GenLauncherGO.Core.Launching.Contracts; + +/// +/// Verifies and resolves launch-readiness integrity state for selected launcher content. +/// +public interface ILaunchContentIntegrityResolutionService +{ + /// + /// Verifies active launch content and returns the target contexts used for any later resolution. + /// + /// The target construction request. + /// A token that cancels verification. + /// The verification report and target contexts. + Task VerifyAsync( + LaunchContentIntegrityTargetRequest request, + CancellationToken cancellationToken); + + /// + /// Captures initial snapshots for managed remote cache targets that already match the expected remote file set. + /// + /// The resolution request containing the verification report and target contexts. + /// A token that cancels snapshot work. + /// when at least one cache target was initialized; otherwise, . + Task InitializeUntrackedManagedCachesAsync( + LaunchContentIntegrityResolutionRequest request, + CancellationToken cancellationToken); + + /// + /// Applies confirmed launch-integrity resolutions, including snapshots, cleanup, package repair, and cache refresh. + /// + /// The resolution request containing the reviewed report and target contexts. + /// Optional progress reporter keyed by integrity target id. + /// A token that cancels resolution work. + Task ResolveAsync( + LaunchContentIntegrityResolutionRequest request, + IProgress? progress, + CancellationToken cancellationToken); + + /// + /// Marks a manually imported version as manual content and captures its initial package and cache snapshots. + /// + /// The request describing the version to snapshot. + /// A token that cancels snapshot work. + Task RegisterManualImportAsync( + LaunchContentIntegrityVersionRequest request, + CancellationToken cancellationToken); + + /// + /// Captures initial snapshots for a newly installed managed remote version. + /// + /// The request describing the version to snapshot. + /// A token that cancels snapshot or cache refresh work. + Task CaptureManagedInstallSnapshotAsync( + LaunchContentIntegrityVersionRequest request, + CancellationToken cancellationToken); + + /// + /// Captures a trusted snapshot for a manually managed cached image target. + /// + /// The request describing the version whose cache image changed. + /// A token that cancels snapshot work. + Task CaptureManualImageSnapshotAsync( + LaunchContentIntegrityVersionRequest request, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Launching/Contracts/ILaunchContentIntegrityTargetBuilder.cs b/GenLauncherGO.Core/Launching/Contracts/ILaunchContentIntegrityTargetBuilder.cs new file mode 100644 index 00000000..a4f30405 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Contracts/ILaunchContentIntegrityTargetBuilder.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Launching.Models; + +namespace GenLauncherGO.Core.Launching.Contracts; + +/// +/// Builds launch-readiness integrity targets for selected launcher content. +/// +public interface ILaunchContentIntegrityTargetBuilder +{ + /// + /// Builds integrity targets for the active launch content. + /// + /// The target construction request. + /// The package and cache targets that should be verified before launch. + IReadOnlyList BuildTargets( + LaunchContentIntegrityTargetRequest request); +} diff --git a/GenLauncherGO.Core/Launching/Contracts/ILaunchPreparationService.cs b/GenLauncherGO.Core/Launching/Contracts/ILaunchPreparationService.cs new file mode 100644 index 00000000..fcb5a202 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Contracts/ILaunchPreparationService.cs @@ -0,0 +1,42 @@ +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Contracts; + +/// +/// Prepares, cleans, and recovers launch-time game-directory state. +/// +public interface ILaunchPreparationService +{ + /// + /// Prepares the game directory for launching the selected content. + /// + /// The launch preparation request. + /// A token that cancels preparation work. + /// The launch preparation result. + Task PrepareAsync( + LaunchPreparationRequest request, + CancellationToken cancellationToken); + + /// + /// Cleans launch-time game-directory state after a launched process exits. + /// + /// The resolved game and launcher paths. + /// A token that cancels cleanup work. + /// The launch preparation cleanup result. + Task CleanupAsync( + LauncherPaths paths, + CancellationToken cancellationToken); + + /// + /// Recovers interrupted launch-time game-directory state during launcher startup. + /// + /// The resolved game and launcher paths. + /// A token that cancels recovery work. + /// The launch preparation recovery result. + Task RecoverAsync( + LauncherPaths paths, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Launching/Models/DeploymentCleanupRequest.cs b/GenLauncherGO.Core/Launching/Models/DeploymentCleanupRequest.cs new file mode 100644 index 00000000..6b614ca0 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/DeploymentCleanupRequest.cs @@ -0,0 +1,27 @@ +using System; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes the launcher paths for a deployment cleanup operation. +/// +public sealed record DeploymentCleanupRequest +{ + /// + /// Initializes a new instance of the record. + /// + /// The resolved game and launcher paths. + /// Thrown when is . + public DeploymentCleanupRequest(LauncherPaths paths) + { + ArgumentNullException.ThrowIfNull(paths); + + Paths = paths; + } + + /// + /// Gets the resolved game and launcher paths. + /// + public LauncherPaths Paths { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/DeploymentFailure.cs b/GenLauncherGO.Core/Launching/Models/DeploymentFailure.cs new file mode 100644 index 00000000..28e140eb --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/DeploymentFailure.cs @@ -0,0 +1,43 @@ +using System; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes one deployment operation failure. +/// +public sealed record DeploymentFailure +{ + /// + /// Initializes a new instance of the record. + /// + /// The failure category. + /// The related path, when a path is involved. + /// The diagnostic failure message. + /// Thrown when is empty or whitespace. + public DeploymentFailure( + DeploymentFailureKind kind, + string path, + string message) + { + ArgumentException.ThrowIfNullOrWhiteSpace(message); + + Kind = kind; + Path = path ?? string.Empty; + Message = message; + } + + /// + /// Gets the failure category. + /// + public DeploymentFailureKind Kind { get; init; } + + /// + /// Gets the related path, when a path is involved. + /// + public string Path { get; init; } + + /// + /// Gets the diagnostic failure message. + /// + public string Message { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/DeploymentFailureKind.cs b/GenLauncherGO.Core/Launching/Models/DeploymentFailureKind.cs new file mode 100644 index 00000000..6b871440 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/DeploymentFailureKind.cs @@ -0,0 +1,22 @@ +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes the failure category for a deployment operation. +/// +public enum DeploymentFailureKind +{ + /// + /// The request was missing required information or contained unsafe paths. + /// + InvalidRequest, + + /// + /// A file-system mutation failed. + /// + FileSystem, + + /// + /// Manifest persistence or recovery failed. + /// + Manifest +} diff --git a/GenLauncherGO.Core/Launching/Models/DeploymentFileEntry.cs b/GenLauncherGO.Core/Launching/Models/DeploymentFileEntry.cs new file mode 100644 index 00000000..6c9e39eb --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/DeploymentFileEntry.cs @@ -0,0 +1,64 @@ +using System; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes one file deployed into the game directory. +/// +public sealed record DeploymentFileEntry +{ + /// + /// Initializes a new instance of the record. + /// + /// The installed package source file path. + /// The game-directory-relative target path. + /// The deployment method used for this file. + /// The deployment-directory-relative backup path for a displaced original file. + /// The package identifier that supplied this file. + /// + /// Thrown when , , or + /// is empty or whitespace. + /// + public DeploymentFileEntry( + string sourcePath, + string targetRelativePath, + DeploymentMethod method, + string? backupRelativePath, + string packageId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sourcePath); + ArgumentException.ThrowIfNullOrWhiteSpace(targetRelativePath); + ArgumentException.ThrowIfNullOrWhiteSpace(packageId); + + SourcePath = sourcePath; + TargetRelativePath = targetRelativePath; + Method = method; + BackupRelativePath = backupRelativePath; + PackageId = packageId; + } + + /// + /// Gets the installed package source file path. + /// + public string SourcePath { get; init; } + + /// + /// Gets the game-directory-relative target path. + /// + public string TargetRelativePath { get; init; } + + /// + /// Gets the deployment method used for this file. + /// + public DeploymentMethod Method { get; init; } + + /// + /// Gets the deployment-directory-relative backup path for a displaced original file. + /// + public string? BackupRelativePath { get; init; } + + /// + /// Gets the package identifier that supplied this file. + /// + public string PackageId { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/DeploymentManifest.cs b/GenLauncherGO.Core/Launching/Models/DeploymentManifest.cs new file mode 100644 index 00000000..fd3f0527 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/DeploymentManifest.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes the files and directories owned by one completed deployment. +/// +public sealed record DeploymentManifest +{ + /// + /// Initializes a new instance of the record. + /// + /// The deployment manifest schema version. + /// The unique deployment identifier. + /// The UTC creation time. + /// The deployed files. + /// The game-directory-relative directories created during deployment. + /// + /// Thrown when is empty or whitespace. + /// + /// + /// Thrown when or is . + /// + public DeploymentManifest( + int schemaVersion, + string deploymentId, + DateTimeOffset createdAtUtc, + IReadOnlyList files, + IReadOnlyList createdDirectories) + { + ArgumentException.ThrowIfNullOrWhiteSpace(deploymentId); + ArgumentNullException.ThrowIfNull(files); + ArgumentNullException.ThrowIfNull(createdDirectories); + + SchemaVersion = schemaVersion; + DeploymentId = deploymentId; + CreatedAtUtc = createdAtUtc; + Files = files.ToArray(); + CreatedDirectories = createdDirectories.ToArray(); + } + + /// + /// Gets the deployment manifest schema version. + /// + public int SchemaVersion { get; init; } + + /// + /// Gets the unique deployment identifier. + /// + public string DeploymentId { get; init; } + + /// + /// Gets the UTC creation time. + /// + public DateTimeOffset CreatedAtUtc { get; init; } + + /// + /// Gets the deployed files. + /// + public IReadOnlyList Files { get; init; } + + /// + /// Gets the game-directory-relative directories created during deployment. + /// + public IReadOnlyList CreatedDirectories { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/DeploymentMethod.cs b/GenLauncherGO.Core/Launching/Models/DeploymentMethod.cs new file mode 100644 index 00000000..a42fb150 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/DeploymentMethod.cs @@ -0,0 +1,17 @@ +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes how a file was deployed into the game directory. +/// +public enum DeploymentMethod +{ + /// + /// The file was deployed by creating a hard link to the installed package file. + /// + HardLink, + + /// + /// The file was deployed by copying the installed package file. + /// + Copy +} diff --git a/GenLauncherGO.Core/Launching/Models/DeploymentPackage.cs b/GenLauncherGO.Core/Launching/Models/DeploymentPackage.cs new file mode 100644 index 00000000..e07493f1 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/DeploymentPackage.cs @@ -0,0 +1,64 @@ +using System; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes one installed package selected for deployment. +/// +public sealed record DeploymentPackage +{ + /// + /// Initializes a new instance of the record. + /// + /// A stable package identifier for manifest diagnostics. + /// The user-facing package name for diagnostics. + /// The package role in the selected deployment set. + /// The installed package root directory. + /// The package precedence; higher values override lower values for the same target path. + /// + /// Thrown when , , or is empty + /// or whitespace. + /// + public DeploymentPackage( + string id, + string displayName, + DeploymentPackageKind kind, + string rootDirectory, + int precedence) + { + ArgumentException.ThrowIfNullOrWhiteSpace(id); + ArgumentException.ThrowIfNullOrWhiteSpace(displayName); + ArgumentException.ThrowIfNullOrWhiteSpace(rootDirectory); + + Id = id; + DisplayName = displayName; + Kind = kind; + RootDirectory = rootDirectory; + Precedence = precedence; + } + + /// + /// Gets a stable package identifier for manifest diagnostics. + /// + public string Id { get; init; } + + /// + /// Gets the user-facing package name for diagnostics. + /// + public string DisplayName { get; init; } + + /// + /// Gets the package role in the selected deployment set. + /// + public DeploymentPackageKind Kind { get; init; } + + /// + /// Gets the installed package root directory. + /// + public string RootDirectory { get; init; } + + /// + /// Gets the package precedence; higher values override lower values for the same target path. + /// + public int Precedence { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/DeploymentPackageKind.cs b/GenLauncherGO.Core/Launching/Models/DeploymentPackageKind.cs new file mode 100644 index 00000000..b6fd4b6f --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/DeploymentPackageKind.cs @@ -0,0 +1,22 @@ +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes the selected package role in a launch deployment. +/// +public enum DeploymentPackageKind +{ + /// + /// The selected base modification package. + /// + Mod, + + /// + /// The selected patch package. + /// + Patch, + + /// + /// A selected add-on package. + /// + Addon +} diff --git a/GenLauncherGO.Core/Launching/Models/DeploymentRecoveryRequest.cs b/GenLauncherGO.Core/Launching/Models/DeploymentRecoveryRequest.cs new file mode 100644 index 00000000..e2aa825a --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/DeploymentRecoveryRequest.cs @@ -0,0 +1,27 @@ +using System; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes the launcher paths for startup deployment recovery. +/// +public sealed record DeploymentRecoveryRequest +{ + /// + /// Initializes a new instance of the record. + /// + /// The resolved game and launcher paths. + /// Thrown when is . + public DeploymentRecoveryRequest(LauncherPaths paths) + { + ArgumentNullException.ThrowIfNull(paths); + + Paths = paths; + } + + /// + /// Gets the resolved game and launcher paths. + /// + public LauncherPaths Paths { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/DeploymentRequest.cs b/GenLauncherGO.Core/Launching/Models/DeploymentRequest.cs new file mode 100644 index 00000000..8b8940e1 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/DeploymentRequest.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes the selected packages and launcher paths for a deployment preparation operation. +/// +public sealed record DeploymentRequest +{ + /// + /// Initializes a new instance of the record. + /// + /// The resolved game and launcher paths. + /// The selected packages to deploy. + /// + /// Thrown when or is . + /// + public DeploymentRequest(LauncherPaths paths, IReadOnlyList packages) + : this(paths, packages, Array.Empty()) + { + } + + /// + /// Initializes a new instance of the record. + /// + /// The resolved game and launcher paths. + /// The selected packages to deploy. + /// The game-directory-relative files to temporarily disable. + /// + /// Thrown when , , or + /// is . + /// + public DeploymentRequest( + LauncherPaths paths, + IReadOnlyList packages, + IReadOnlyList disabledTargetRelativePaths) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(packages); + ArgumentNullException.ThrowIfNull(disabledTargetRelativePaths); + + Paths = paths; + Packages = packages.ToArray(); + DisabledTargetRelativePaths = disabledTargetRelativePaths + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(NormalizeDisabledTargetPath) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + } + + /// + /// Gets the resolved game and launcher paths. + /// + public LauncherPaths Paths { get; init; } + + /// + /// Gets the selected packages to deploy. + /// + public IReadOnlyList Packages { get; init; } + + /// + /// Gets game-directory-relative files to temporarily disable until deployment cleanup restores them. + /// + public IReadOnlyList DisabledTargetRelativePaths { get; init; } + + /// + /// Normalizes a disabled target path for stable request storage. + /// + /// The disabled target path. + /// The normalized disabled target path. + private static string NormalizeDisabledTargetPath(string path) + { + return path.Trim().Replace('\\', '/'); + } +} diff --git a/GenLauncherGO.Core/Launching/Models/DeploymentResult.cs b/GenLauncherGO.Core/Launching/Models/DeploymentResult.cs new file mode 100644 index 00000000..b61ff7bd --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/DeploymentResult.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes the outcome of a deployment operation. +/// +public sealed record DeploymentResult +{ + /// + /// Initializes a new instance of the record. + /// + /// Whether the operation completed successfully. + /// The operation failures. + /// The manifest involved in the operation, when one was produced or loaded. + /// Thrown when is . + public DeploymentResult( + bool succeeded, + IReadOnlyList failures, + DeploymentManifest? manifest) + { + ArgumentNullException.ThrowIfNull(failures); + + Succeeded = succeeded; + Failures = failures.ToArray(); + Manifest = manifest; + } + + /// + /// Gets a value indicating whether the operation completed successfully. + /// + public bool Succeeded { get; init; } + + /// + /// Gets the operation failures. + /// + public IReadOnlyList Failures { get; init; } + + /// + /// Gets the manifest involved in the operation, when one was produced or loaded. + /// + public DeploymentManifest? Manifest { get; init; } + + /// + /// Creates a successful deployment result. + /// + /// The manifest involved in the operation. + /// A successful result. + public static DeploymentResult Success(DeploymentManifest? manifest = null) + { + return new DeploymentResult(true, Array.Empty(), manifest); + } + + /// + /// Creates a failed deployment result. + /// + /// The failure to include in the result. + /// A failed result. + public static DeploymentResult Failure(DeploymentFailure failure) + { + ArgumentNullException.ThrowIfNull(failure); + + return new DeploymentResult(false, new[] { failure }, null); + } +} diff --git a/GenLauncherGO.Core/Launching/Models/GameClientExecutable.cs b/GenLauncherGO.Core/Launching/Models/GameClientExecutable.cs new file mode 100644 index 00000000..fa2e24e2 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/GameClientExecutable.cs @@ -0,0 +1,35 @@ +using System; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes a game client executable available to the launcher. +/// +public sealed class GameClientExecutable +{ + /// + /// Initializes a new instance of the class. + /// + /// The executable file name or path. + /// The game client kind. + public GameClientExecutable(string executableName, GameClientExecutableKind kind) + { + if (string.IsNullOrWhiteSpace(executableName)) + { + throw new ArgumentException("Executable name cannot be empty.", nameof(executableName)); + } + + ExecutableName = executableName; + Kind = kind; + } + + /// + /// Gets the executable file name or path. + /// + public string ExecutableName { get; } + + /// + /// Gets the game client kind. + /// + public GameClientExecutableKind Kind { get; } +} diff --git a/GenLauncherGO.Core/Launching/Models/GameClientExecutableKind.cs b/GenLauncherGO.Core/Launching/Models/GameClientExecutableKind.cs new file mode 100644 index 00000000..b1cd5bb2 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/GameClientExecutableKind.cs @@ -0,0 +1,17 @@ +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Identifies the supported game client executable variants. +/// +public enum GameClientExecutableKind +{ + /// + /// The community executable for the managed game variant. + /// + Community, + + /// + /// The Generals Online launcher executable. + /// + GeneralsOnline, +} diff --git a/GenLauncherGO.Core/Launching/Models/GameLaunchRequest.cs b/GenLauncherGO.Core/Launching/Models/GameLaunchRequest.cs new file mode 100644 index 00000000..a4b59142 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/GameLaunchRequest.cs @@ -0,0 +1,104 @@ +using System; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes a game or World Builder process launch request. +/// +public sealed record GameLaunchRequest +{ + /// + /// Initializes a new instance of the record. + /// + /// The kind of process being launched. + /// The game variant managed by the current launcher session. + /// A value indicating whether the Generals Online client should be used. + /// The explicitly selected executable name for tool launches. + /// The command-line arguments requested by the user. + /// + /// Thrown when is and + /// is empty or whitespace. + /// + public GameLaunchRequest( + GameLaunchTargetKind targetKind, + SupportedGame managedGame, + bool useGeneralsOnline, + string? executableName, + string? arguments) + { + if (targetKind == GameLaunchTargetKind.WorldBuilder) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executableName); + } + + TargetKind = targetKind; + ManagedGame = managedGame; + UseGeneralsOnline = useGeneralsOnline; + ExecutableName = executableName ?? string.Empty; + Arguments = arguments ?? string.Empty; + } + + /// + /// Gets the kind of process being launched. + /// + public GameLaunchTargetKind TargetKind { get; init; } + + /// + /// Gets the game variant managed by the current launcher session. + /// + public SupportedGame ManagedGame { get; init; } + + /// + /// Gets a value indicating whether the Generals Online client should be used. + /// + public bool UseGeneralsOnline { get; init; } + + /// + /// Gets the explicitly selected executable name for tool launches. + /// + public string ExecutableName { get; init; } + + /// + /// Gets the command-line arguments requested by the user. + /// + public string Arguments { get; init; } + + /// + /// Creates a game-client launch request. + /// + /// The game variant managed by the current launcher session. + /// A value indicating whether the Generals Online client should be used. + /// The command-line arguments requested by the user. + /// The game-client launch request. + public static GameLaunchRequest ForGameClient( + SupportedGame managedGame, + bool useGeneralsOnline, + string? arguments) + { + return new GameLaunchRequest( + GameLaunchTargetKind.GameClient, + managedGame, + useGeneralsOnline, + executableName: null, + arguments); + } + + /// + /// Creates a World Builder launch request. + /// + /// The selected World Builder executable name. + /// The command-line arguments requested by the user. + /// The World Builder launch request. + public static GameLaunchRequest ForWorldBuilder( + string executableName, + string? arguments) + { + return new GameLaunchRequest( + GameLaunchTargetKind.WorldBuilder, + SupportedGame.Unknown, + useGeneralsOnline: false, + executableName, + arguments); + } +} diff --git a/GenLauncherGO.Core/Launching/Models/GameLaunchResult.cs b/GenLauncherGO.Core/Launching/Models/GameLaunchResult.cs new file mode 100644 index 00000000..dbe48ffe --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/GameLaunchResult.cs @@ -0,0 +1,93 @@ +using System; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes the outcome of a game or tool process launch. +/// +public sealed record GameLaunchResult +{ + /// + /// Initializes a new instance of the record. + /// + /// A value indicating whether the launch satisfied the expected success condition. + /// The executable name used for the process launch. + /// The command-line arguments used for the process launch. + /// The observed running duration for the launched process family. + /// The failure message, when the process ran but did not satisfy launch success criteria. + /// Thrown when is empty or whitespace. + public GameLaunchResult( + bool succeeded, + string executableName, + string arguments, + TimeSpan runningDuration, + string? failureMessage) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executableName); + + Succeeded = succeeded; + ExecutableName = executableName; + Arguments = arguments; + RunningDuration = runningDuration; + FailureMessage = failureMessage; + } + + /// + /// Gets a value indicating whether the launch satisfied the expected success condition. + /// + public bool Succeeded { get; init; } + + /// + /// Gets the executable name used for the process launch. + /// + public string ExecutableName { get; init; } + + /// + /// Gets the command-line arguments used for the process launch. + /// + public string Arguments { get; init; } + + /// + /// Gets the observed running duration for the launched process family. + /// + public TimeSpan RunningDuration { get; init; } + + /// + /// Gets the failure message, when the process ran but did not satisfy launch success criteria. + /// + public string? FailureMessage { get; init; } + + /// + /// Creates a successful game launch result. + /// + /// The executable name used for the process launch. + /// The command-line arguments used for the process launch. + /// The observed running duration for the launched process family. + /// The successful game launch result. + public static GameLaunchResult Success( + string executableName, + string arguments, + TimeSpan runningDuration) + { + return new GameLaunchResult(true, executableName, arguments, runningDuration, failureMessage: null); + } + + /// + /// Creates a failed game launch result. + /// + /// The executable name used for the process launch. + /// The command-line arguments used for the process launch. + /// The observed running duration for the launched process family. + /// The failure message. + /// The failed game launch result. + public static GameLaunchResult Failure( + string executableName, + string arguments, + TimeSpan runningDuration, + string failureMessage) + { + ArgumentException.ThrowIfNullOrWhiteSpace(failureMessage); + + return new GameLaunchResult(false, executableName, arguments, runningDuration, failureMessage); + } +} diff --git a/GenLauncherGO.Core/Launching/Models/GameLaunchTargetKind.cs b/GenLauncherGO.Core/Launching/Models/GameLaunchTargetKind.cs new file mode 100644 index 00000000..10af7960 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/GameLaunchTargetKind.cs @@ -0,0 +1,17 @@ +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Identifies the kind of process being launched. +/// +public enum GameLaunchTargetKind +{ + /// + /// The selected game client process. + /// + GameClient, + + /// + /// The selected World Builder tool process. + /// + WorldBuilder +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionProgress.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionProgress.cs new file mode 100644 index 00000000..13df67b6 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionProgress.cs @@ -0,0 +1,69 @@ +using System; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Reports progress for one launch-integrity resolution target. +/// +public sealed record LaunchContentIntegrityResolutionProgress +{ + /// + /// Initializes a new instance of the record. + /// + /// The integrity target id associated with the progress update. + /// The package update progress when a package is being repaired. + /// A value indicating whether target resolution completed without package progress. + /// Thrown when is empty or whitespace. + public LaunchContentIntegrityResolutionProgress( + string targetId, + PackageUpdateProgress? packageProgress, + bool completed) + { + ArgumentException.ThrowIfNullOrWhiteSpace(targetId); + + TargetId = targetId; + PackageProgress = packageProgress; + Completed = completed; + } + + /// + /// Gets the integrity target id associated with the progress update. + /// + public string TargetId { get; init; } + + /// + /// Gets the package update progress when a package is being repaired. + /// + public PackageUpdateProgress? PackageProgress { get; init; } + + /// + /// Gets a value indicating whether target resolution completed without package progress. + /// + public bool Completed { get; init; } + + /// + /// Creates a package-progress update. + /// + /// The integrity target id associated with the progress update. + /// The package update progress. + /// The progress update. + public static LaunchContentIntegrityResolutionProgress Package( + string targetId, + PackageUpdateProgress packageProgress) + { + ArgumentNullException.ThrowIfNull(packageProgress); + + return new LaunchContentIntegrityResolutionProgress(targetId, packageProgress, completed: false); + } + + /// + /// Creates a completion update. + /// + /// The integrity target id associated with the progress update. + /// The progress update. + public static LaunchContentIntegrityResolutionProgress Complete(string targetId) + { + return new LaunchContentIntegrityResolutionProgress(targetId, packageProgress: null, completed: true); + } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionRequest.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionRequest.cs new file mode 100644 index 00000000..3c942d76 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionRequest.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes a launch-integrity resolution operation. +/// +public sealed record LaunchContentIntegrityResolutionRequest +{ + /// + /// Initializes a new instance of the record. + /// + /// The resolved game and launcher paths. + /// The reviewed integrity report. + /// The target contexts used to verify the report. + /// + /// Thrown when , , or + /// is . + /// + public LaunchContentIntegrityResolutionRequest( + LauncherPaths paths, + ContentIntegrityReport report, + IReadOnlyList targetContexts) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(report); + ArgumentNullException.ThrowIfNull(targetContexts); + + Paths = paths; + Report = report; + TargetContexts = targetContexts.ToArray(); + } + + /// + /// Gets the resolved game and launcher paths. + /// + public LauncherPaths Paths { get; init; } + + /// + /// Gets the reviewed integrity report. + /// + public ContentIntegrityReport Report { get; init; } + + /// + /// Gets the target contexts used to verify the report. + /// + public IReadOnlyList TargetContexts { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetContext.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetContext.cs new file mode 100644 index 00000000..c8ba66ba --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetContext.cs @@ -0,0 +1,48 @@ +using System; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Connects an integrity target to the selected content version that owns it. +/// +public sealed record LaunchContentIntegrityTargetContext +{ + /// + /// Initializes a new instance of the record. + /// + /// The integrity target to verify. + /// The selected content version that owns the target. + /// A value indicating whether the target describes cached launcher assets. + /// + /// Thrown when or is . + /// + public LaunchContentIntegrityTargetContext( + ContentIntegrityTarget target, + ModificationVersion version, + bool isCache) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(version); + + Target = target; + Version = version; + IsCache = isCache; + } + + /// + /// Gets the integrity target to verify. + /// + public ContentIntegrityTarget Target { get; init; } + + /// + /// Gets the selected content version that owns the target. + /// + public ModificationVersion Version { get; init; } + + /// + /// Gets a value indicating whether the target describes cached launcher assets. + /// + public bool IsCache { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetRequest.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetRequest.cs new file mode 100644 index 00000000..d9af3583 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetRequest.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes the selected content and launcher paths used to build launch-readiness integrity targets. +/// +public sealed record LaunchContentIntegrityTargetRequest +{ + /// + /// Initializes a new instance of the record. + /// + /// The resolved game and launcher paths. + /// The selected content versions that should be verified before launch. + /// All known content versions used to identify inactive cache files. + /// The localized suffix appended to cache target display names. + public LaunchContentIntegrityTargetRequest( + LauncherPaths paths, + IReadOnlyList activeVersions, + IReadOnlyList allVersions, + string cacheDisplayNameSuffix) + : this( + paths, + activeVersions, + allVersions, + cacheDisplayNameSuffix, + LaunchPreparationRequest.DefaultAddonsFolderName, + LaunchPreparationRequest.DefaultPatchesFolderName) + { + } + + /// + /// Initializes a new instance of the record. + /// + /// The resolved game and launcher paths. + /// The selected content versions that should be verified before launch. + /// All known content versions used to identify inactive cache files. + /// The localized suffix appended to cache target display names. + /// The folder name that contains add-on packages below a modification. + /// The folder name that contains patch packages below a modification. + /// + /// Thrown when , , or + /// is . + /// + /// + /// Thrown when , , or + /// is empty or whitespace. + /// + public LaunchContentIntegrityTargetRequest( + LauncherPaths paths, + IReadOnlyList activeVersions, + IReadOnlyList allVersions, + string cacheDisplayNameSuffix, + string addonsFolderName, + string patchesFolderName) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(activeVersions); + ArgumentNullException.ThrowIfNull(allVersions); + ArgumentException.ThrowIfNullOrWhiteSpace(cacheDisplayNameSuffix); + ArgumentException.ThrowIfNullOrWhiteSpace(addonsFolderName); + ArgumentException.ThrowIfNullOrWhiteSpace(patchesFolderName); + + Paths = paths; + ActiveVersions = activeVersions + .Where(version => version is not null) + .Cast() + .ToArray(); + AllVersions = allVersions + .Where(version => version is not null) + .Cast() + .ToArray(); + CacheDisplayNameSuffix = cacheDisplayNameSuffix; + AddonsFolderName = addonsFolderName; + PatchesFolderName = patchesFolderName; + } + + /// + /// Gets the resolved game and launcher paths. + /// + public LauncherPaths Paths { get; init; } + + /// + /// Gets the selected content versions that should be verified before launch. + /// + public IReadOnlyList ActiveVersions { get; init; } + + /// + /// Gets all known content versions used to identify inactive cache files. + /// + public IReadOnlyList AllVersions { get; init; } + + /// + /// Gets the localized suffix appended to cache target display names. + /// + public string CacheDisplayNameSuffix { get; init; } + + /// + /// Gets the folder name that contains add-on packages below a modification. + /// + public string AddonsFolderName { get; init; } + + /// + /// Gets the folder name that contains patch packages below a modification. + /// + public string PatchesFolderName { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVerificationResult.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVerificationResult.cs new file mode 100644 index 00000000..74edb52d --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVerificationResult.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes the result of launch-readiness verification. +/// +public sealed record LaunchContentIntegrityVerificationResult +{ + /// + /// Initializes a new instance of the record. + /// + /// The integrity verification report. + /// The target contexts used to produce the report. + /// + /// Thrown when or is . + /// + public LaunchContentIntegrityVerificationResult( + ContentIntegrityReport report, + IReadOnlyList targetContexts) + { + ArgumentNullException.ThrowIfNull(report); + ArgumentNullException.ThrowIfNull(targetContexts); + + Report = report; + TargetContexts = targetContexts.ToArray(); + } + + /// + /// Gets the integrity verification report. + /// + public ContentIntegrityReport Report { get; init; } + + /// + /// Gets the target contexts used to produce the report. + /// + public IReadOnlyList TargetContexts { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVersionRequest.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVersionRequest.cs new file mode 100644 index 00000000..aba5fffa --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVersionRequest.cs @@ -0,0 +1,108 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes an integrity operation for a single launcher content version. +/// +public sealed record LaunchContentIntegrityVersionRequest +{ + /// + /// Initializes a new instance of the record. + /// + /// The resolved game and launcher paths. + /// The content version to process. + /// All known content versions used to identify inactive cache files. + /// The localized suffix appended to cache target display names. + public LaunchContentIntegrityVersionRequest( + LauncherPaths paths, + ModificationVersion version, + IReadOnlyList allVersions, + string cacheDisplayNameSuffix) + : this( + paths, + version, + allVersions, + cacheDisplayNameSuffix, + LaunchPreparationRequest.DefaultAddonsFolderName, + LaunchPreparationRequest.DefaultPatchesFolderName) + { + } + + /// + /// Initializes a new instance of the record. + /// + /// The resolved game and launcher paths. + /// The content version to process. + /// All known content versions used to identify inactive cache files. + /// The localized suffix appended to cache target display names. + /// The folder name that contains add-on packages below a modification. + /// The folder name that contains patch packages below a modification. + /// + /// Thrown when , , or + /// is . + /// + /// + /// Thrown when , , or + /// is empty or whitespace. + /// + public LaunchContentIntegrityVersionRequest( + LauncherPaths paths, + ModificationVersion version, + IReadOnlyList allVersions, + string cacheDisplayNameSuffix, + string addonsFolderName, + string patchesFolderName) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(version); + ArgumentNullException.ThrowIfNull(allVersions); + ArgumentException.ThrowIfNullOrWhiteSpace(cacheDisplayNameSuffix); + ArgumentException.ThrowIfNullOrWhiteSpace(addonsFolderName); + ArgumentException.ThrowIfNullOrWhiteSpace(patchesFolderName); + + Paths = paths; + Version = version; + AllVersions = allVersions + .Where(candidate => candidate is not null) + .Cast() + .ToArray(); + CacheDisplayNameSuffix = cacheDisplayNameSuffix; + AddonsFolderName = addonsFolderName; + PatchesFolderName = patchesFolderName; + } + + /// + /// Gets the resolved game and launcher paths. + /// + public LauncherPaths Paths { get; init; } + + /// + /// Gets the content version to process. + /// + public ModificationVersion Version { get; init; } + + /// + /// Gets all known content versions used to identify inactive cache files. + /// + public IReadOnlyList AllVersions { get; init; } + + /// + /// Gets the localized suffix appended to cache target display names. + /// + public string CacheDisplayNameSuffix { get; init; } + + /// + /// Gets the folder name that contains add-on packages below a modification. + /// + public string AddonsFolderName { get; init; } + + /// + /// Gets the folder name that contains patch packages below a modification. + /// + public string PatchesFolderName { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchPreparationRequest.cs b/GenLauncherGO.Core/Launching/Models/LaunchPreparationRequest.cs new file mode 100644 index 00000000..bb9ef09d --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchPreparationRequest.cs @@ -0,0 +1,120 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes selected launcher content that must be prepared before launching the game. +/// +public sealed record LaunchPreparationRequest +{ + /// + /// The default folder name that contains add-on packages below a modification. + /// + public const string DefaultAddonsFolderName = "Addons"; + + /// + /// The default folder name that contains patch packages below a modification. + /// + public const string DefaultPatchesFolderName = "Patches"; + + /// + /// Initializes a new instance of the record. + /// + /// The resolved game and launcher paths. + /// The selected installed content versions. + public LaunchPreparationRequest( + LauncherPaths paths, + IReadOnlyList versions) + : this(paths, versions, DefaultAddonsFolderName, DefaultPatchesFolderName) + { + } + + /// + /// Initializes a new instance of the record. + /// + /// The resolved game and launcher paths. + /// The selected installed content versions. + /// The folder name that contains add-on packages below a modification. + /// The folder name that contains patch packages below a modification. + /// + /// Thrown when or is . + /// + /// + /// Thrown when or is empty or whitespace. + /// + public LaunchPreparationRequest( + LauncherPaths paths, + IReadOnlyList versions, + string addonsFolderName, + string patchesFolderName) + : this(paths, versions, addonsFolderName, patchesFolderName, disableBaseGameScriptFiles: false) + { + } + + /// + /// Initializes a new instance of the record. + /// + /// The resolved game and launcher paths. + /// The selected installed content versions. + /// The folder name that contains add-on packages below a modification. + /// The folder name that contains patch packages below a modification. + /// + /// A value indicating whether base game script files should be temporarily disabled during deployment. + /// + /// + /// Thrown when or is . + /// + /// + /// Thrown when or is empty or whitespace. + /// + public LaunchPreparationRequest( + LauncherPaths paths, + IReadOnlyList versions, + string addonsFolderName, + string patchesFolderName, + bool disableBaseGameScriptFiles) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(versions); + ArgumentException.ThrowIfNullOrWhiteSpace(addonsFolderName); + ArgumentException.ThrowIfNullOrWhiteSpace(patchesFolderName); + + Paths = paths; + Versions = versions + .Where(version => version is not null) + .Cast() + .ToArray(); + AddonsFolderName = addonsFolderName; + PatchesFolderName = patchesFolderName; + DisableBaseGameScriptFiles = disableBaseGameScriptFiles; + } + + /// + /// Gets the resolved game and launcher paths. + /// + public LauncherPaths Paths { get; init; } + + /// + /// Gets the selected installed content versions. + /// + public IReadOnlyList Versions { get; init; } + + /// + /// Gets the folder name that contains add-on packages below a modification. + /// + public string AddonsFolderName { get; init; } + + /// + /// Gets the folder name that contains patch packages below a modification. + /// + public string PatchesFolderName { get; init; } + + /// + /// Gets a value indicating whether base game script files should be temporarily disabled during deployment. + /// + public bool DisableBaseGameScriptFiles { get; init; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchPreparationResult.cs b/GenLauncherGO.Core/Launching/Models/LaunchPreparationResult.cs new file mode 100644 index 00000000..0bea11a7 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchPreparationResult.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes the result of preparing, cleaning, or recovering launch-time deployment state. +/// +public sealed record LaunchPreparationResult +{ + /// + /// Initializes a new instance of the record. + /// + /// A value indicating whether the operation succeeded. + /// The deployment failures that prevented success. + /// The deployment manifest associated with the operation, when available. + /// Thrown when is . + public LaunchPreparationResult( + bool succeeded, + IReadOnlyList failures, + DeploymentManifest? manifest) + { + ArgumentNullException.ThrowIfNull(failures); + + Succeeded = succeeded; + Failures = failures.ToArray(); + Manifest = manifest; + } + + /// + /// Gets a value indicating whether the operation succeeded. + /// + public bool Succeeded { get; init; } + + /// + /// Gets the deployment failures that prevented success. + /// + public IReadOnlyList Failures { get; init; } + + /// + /// Gets the deployment manifest associated with the operation, when available. + /// + public DeploymentManifest? Manifest { get; init; } + + /// + /// Creates a successful launch preparation result. + /// + /// The deployment manifest associated with the operation, when available. + /// The successful launch preparation result. + public static LaunchPreparationResult Success(DeploymentManifest? manifest = null) + { + return new LaunchPreparationResult(true, Array.Empty(), manifest); + } + + /// + /// Creates a failed launch preparation result. + /// + /// The deployment failures that prevented success. + /// The deployment manifest associated with the operation, when available. + /// The failed launch preparation result. + public static LaunchPreparationResult Failure( + IReadOnlyList failures, + DeploymentManifest? manifest = null) + { + return new LaunchPreparationResult(false, failures, manifest); + } + + /// + /// Converts a deployment result into a launch preparation result. + /// + /// The deployment result. + /// The converted launch preparation result. + /// Thrown when is . + public static LaunchPreparationResult FromDeploymentResult(DeploymentResult result) + { + ArgumentNullException.ThrowIfNull(result); + + return new LaunchPreparationResult(result.Succeeded, result.Failures, result.Manifest); + } +} diff --git a/GenLauncherGO.Core/Launching/Models/WorldBuilderExecutable.cs b/GenLauncherGO.Core/Launching/Models/WorldBuilderExecutable.cs new file mode 100644 index 00000000..d8ae362c --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/WorldBuilderExecutable.cs @@ -0,0 +1,35 @@ +using System; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes a World Builder executable available to the launcher. +/// +public sealed class WorldBuilderExecutable +{ + /// + /// Initializes a new instance of the class. + /// + /// The executable file name or path. + /// The World Builder executable kind. + public WorldBuilderExecutable(string executableName, WorldBuilderExecutableKind kind) + { + if (string.IsNullOrWhiteSpace(executableName)) + { + throw new ArgumentException("Executable name cannot be empty.", nameof(executableName)); + } + + ExecutableName = executableName; + Kind = kind; + } + + /// + /// Gets the executable file name or path. + /// + public string ExecutableName { get; } + + /// + /// Gets the World Builder executable kind. + /// + public WorldBuilderExecutableKind Kind { get; } +} diff --git a/GenLauncherGO.Core/Launching/Models/WorldBuilderExecutableKind.cs b/GenLauncherGO.Core/Launching/Models/WorldBuilderExecutableKind.cs new file mode 100644 index 00000000..3468f3dd --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/WorldBuilderExecutableKind.cs @@ -0,0 +1,17 @@ +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Identifies the supported World Builder executable variants. +/// +public enum WorldBuilderExecutableKind +{ + /// + /// The original game World Builder executable. + /// + Vanilla, + + /// + /// The community World Builder executable for the managed game variant. + /// + Community, +} diff --git a/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogCommands.cs b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogCommands.cs new file mode 100644 index 00000000..567db6dd --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogCommands.cs @@ -0,0 +1,54 @@ +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Mods.Contracts; + +/// +/// Applies mutations to the current launcher content catalog and its local persisted state. +/// +public interface ILauncherContentCatalogCommands +{ + /// + /// Adds or updates a modification in the current catalog. + /// + /// The modification version to add or update. + void AddModModification(ModificationVersion modification); + + /// + /// Clears selected state from all modification cards. + /// + void UnselectAllModifications(); + + /// + /// Deletes a content version from the current catalog and local installation. + /// + /// The version to delete. + void DeleteVersion(ModificationVersion version); + + /// + /// Deletes a content version from the current catalog and local installation. + /// + /// The version to delete. + void DeleteModificationVersion(ModificationVersion modificationVersion); + + /// + /// Deletes a content version from local installation and removes it from the current catalog. + /// + /// The version to remove. + void RemoveContentVersion(ModificationVersion modificationVersion); + + /// + /// Deletes all local files for a content card and removes it from the current catalog. + /// + /// A version that identifies the content card to remove. + void RemoveContent(ModificationVersion modificationVersion); + + /// + /// Refreshes the catalog from locally installed content and removes stale local-only cards. + /// + void UpdateLocalModificationsData(); + + /// + /// Saves the current catalog selection and installed state. + /// + void SaveLauncherData(); +} diff --git a/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogLoader.cs b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogLoader.cs new file mode 100644 index 00000000..006ea574 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogLoader.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Mods.Contracts; + +/// +/// Loads local and remote launcher content catalog data. +/// +public interface ILauncherContentCatalogLoader +{ + /// + /// Gets the modification names advertised by the remote repository. + /// + IReadOnlyList? ReposModsNames { get; } + + /// + /// Initializes the catalog from local state and, when available, the remote repository. + /// + /// The initialization request. + /// The token used to cancel remote work. + /// A task that completes when initialization has finished. + Task InitDataAsync( + LauncherContentCatalogInitializationRequest request, + CancellationToken cancellationToken); + + /// + /// Reads add-ons and patches that belong to the original game. + /// + /// The token used to cancel remote work. + /// A task that completes when original-game child content has been loaded. + Task ReadOriginalGameAddonsAndPatchesAsync(CancellationToken cancellationToken); + + /// + /// Downloads one modification's remote manifest data by name. + /// + /// The modification name. + /// The token used to cancel remote work. + /// The downloaded modification version. + Task DownloadModificationDataFromReposAsync( + string name, + CancellationToken cancellationToken); + + /// + /// Reads remote patches and add-ons for a modification. + /// + /// The parent modification. + /// The token used to cancel remote work. + /// A task that completes when child content has been loaded. + Task ReadPatchesAndAddonsForModAsync( + ModificationReposVersion modification, + CancellationToken cancellationToken); + + /// + /// Reads persisted launcher content state into the current catalog. + /// + void ReadLocalModsData(); +} diff --git a/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogQueries.cs b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogQueries.cs new file mode 100644 index 00000000..0877b948 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogQueries.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Mods.Contracts; + +/// +/// Provides read-only queries over the current launcher content catalog. +/// +public interface ILauncherContentCatalogQueries +{ + /// + /// Gets all modification cards. + /// + /// The current modification cards. + IReadOnlyList GetMods(); + + /// + /// Gets the active advertising modification. + /// + /// The active advertising modification, or when none exists. + ModificationVersion? GetAdvertising(); + + /// + /// Gets the selected modification card. + /// + /// The selected modification card, or when none is selected. + GameModification? GetSelectedMod(); + + /// + /// Gets the selected modification version. + /// + /// The selected modification version, or when none is selected. + ModificationVersion? GetSelectedModVersion(); + + /// + /// Gets the selected patch version. + /// + /// The selected patch version, or when none is selected. + ModificationVersion? GetSelectedPatchVersion(); + + /// + /// Gets all modification names. + /// + /// The current modification names. + IReadOnlyList GetAllModificationsNames(); + + /// + /// Gets patches that belong to the selected modification or original game. + /// + /// The matching patch cards. + IReadOnlyList GetPatchesForSelectedMod(); + + /// + /// Gets add-ons that belong to the selected modification or selected patch. + /// + /// The matching add-on cards. + IReadOnlyList GetAddonsForSelectedMod(); + + /// + /// Gets all versions for the selected modification. + /// + /// The selected modification versions. + IReadOnlyList GetSelectedModVersions(); + + /// + /// Gets selected add-on versions. + /// + /// The selected add-on versions. + IReadOnlyList GetSelectedAddonsVersions(); + + /// + /// Gets selected add-on cards for the selected modification. + /// + /// The selected add-on cards. + IReadOnlyList GetSelectedAddonsForSelectedMod(); + + /// + /// Gets the selected patch card. + /// + /// The selected patch card, or when none is selected. + GameModification? GetSelectedPatch(); + + /// + /// Gets all modification versions. + /// + /// All modification versions. + IReadOnlyList GetAllModsVersionsList(); + + /// + /// Gets add-on versions associated with a modification name. + /// + /// The modification name. + /// The matching add-on versions. + IReadOnlyList GetAddonVersionsForModList(string modName); + + /// + /// Gets patch versions associated with a modification name. + /// + /// The modification name. + /// The matching patch versions. + IReadOnlyList GetPatchVersionsForModList(string modName); +} diff --git a/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogService.cs b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogService.cs new file mode 100644 index 00000000..9e567079 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalogService.cs @@ -0,0 +1,11 @@ +namespace GenLauncherGO.Core.Mods.Contracts; + +/// +/// Aggregates launcher content catalog loading, query, and command contracts for compatibility. +/// +public interface ILauncherContentCatalogService : + ILauncherContentCatalogLoader, + ILauncherContentCatalogQueries, + ILauncherContentCatalogCommands +{ +} diff --git a/GenLauncherGO.Core/Mods/Contracts/ILauncherContentPathResolver.cs b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentPathResolver.cs new file mode 100644 index 00000000..ece0dace --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentPathResolver.cs @@ -0,0 +1,22 @@ +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Mods.Contracts; + +/// +/// Resolves launcher-owned content paths for tracked modification versions. +/// +public interface ILauncherContentPathResolver +{ + /// + /// Builds the installed version directory path for a launcher content version. + /// + /// The resolved launcher paths. + /// The launcher content folder layout. + /// The modification version to resolve. + /// The installed version directory path, or an empty string for unsupported content types. + string GetVersionDirectoryPath( + LauncherPaths paths, + LauncherContentLayout layout, + ModificationVersion version); +} diff --git a/GenLauncherGO.Core/Mods/Contracts/ILauncherContentStateStore.cs b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentStateStore.cs new file mode 100644 index 00000000..fcabf918 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentStateStore.cs @@ -0,0 +1,21 @@ +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Mods.Contracts; + +/// +/// Loads and saves the compact launcher content state. +/// +public interface ILauncherContentStateStore +{ + /// + /// Loads persisted launcher content state. + /// + /// The persisted launcher content state, or an empty state when none can be loaded. + LauncherContentState Load(); + + /// + /// Saves launcher content state. + /// + /// The state to save. + void Save(LauncherContentState state); +} diff --git a/GenLauncherGO.Core/Mods/Contracts/ILocalLauncherContentService.cs b/GenLauncherGO.Core/Mods/Contracts/ILocalLauncherContentService.cs new file mode 100644 index 00000000..10ac3115 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/ILocalLauncherContentService.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Mods.Contracts; + +/// +/// Provides local file-system operations for launcher-managed content. +/// +public interface ILocalLauncherContentService +{ + /// + /// Finds installed content versions under the launcher-owned mods directory. + /// + /// The resolved launcher paths. + /// The content folder layout. + /// The installed content versions discovered locally. + IReadOnlyList FindInstalledVersions( + LauncherPaths paths, + LauncherContentLayout layout); + + /// + /// Determines whether a content version folder exists locally. + /// + /// The resolved launcher paths. + /// The content folder layout. + /// The content version to inspect. + /// when the version folder exists. + bool VersionFolderExists( + LauncherPaths paths, + LauncherContentLayout layout, + LauncherContentVersionState version); + + /// + /// Determines whether a content version folder exists and contains at least one file. + /// + /// The resolved launcher paths. + /// The content folder layout. + /// The content version to inspect. + /// when the version folder exists and contains files. + bool VersionFolderContainsFiles( + LauncherPaths paths, + LauncherContentLayout layout, + LauncherContentVersionState version); + + /// + /// Deletes an installed content version from the launcher-owned mods directory. + /// + /// The resolved launcher paths. + /// The content folder layout. + /// The content version to delete. + void DeleteVersion( + LauncherPaths paths, + LauncherContentLayout layout, + LauncherContentVersionState version); + + /// + /// Deletes all installed content files for a content card from the launcher-owned mods directory. + /// + /// The launcher paths that define the local content root. + /// The content folder layout. + /// A version that identifies the content card to delete. + void DeleteContent( + LauncherPaths paths, + LauncherContentLayout layout, + LauncherContentVersionState version); + + /// + /// Deletes cached images for a content version when no content card still references the same content name. + /// + /// The resolved launcher paths. + /// The content version whose images may be removed. + /// The current launcher content state after any catalog mutation. + void DeleteImagesIfUnused( + LauncherPaths paths, + LauncherContentVersionState version, + LauncherContentState currentState); +} diff --git a/GenLauncherGO.Core/Mods/Contracts/IManualModificationImporter.cs b/GenLauncherGO.Core/Mods/Contracts/IManualModificationImporter.cs new file mode 100644 index 00000000..f5041990 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/IManualModificationImporter.cs @@ -0,0 +1,19 @@ +using System.Threading; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Mods.Contracts; + +/// +/// Imports user-selected modification files into a launcher-managed content folder. +/// +public interface IManualModificationImporter +{ + /// + /// Imports the requested files into the destination directory. + /// + /// The manual import request to process. + /// A token that can cancel the import between files and archive entries. + void Import( + ManualModificationImportRequest request, + CancellationToken cancellationToken = default); +} diff --git a/GenLauncherGO.Core/Mods/Contracts/IModificationImageFileService.cs b/GenLauncherGO.Core/Mods/Contracts/IModificationImageFileService.cs new file mode 100644 index 00000000..c916fe8c --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/IModificationImageFileService.cs @@ -0,0 +1,50 @@ +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Mods.Contracts; + +/// +/// Provides launcher modification image cache file operations. +/// +public interface IModificationImageFileService +{ + /// + /// Finds an existing cached modification image with any extension. + /// + /// The modification name. + /// The image file base name without extension. + /// The first matching image file path, or when none exists. + string? FindExistingImageFilePath(string modificationName, string imageBaseName); + + /// + /// Counts cached image files for a modification. + /// + /// The modification name. + /// The cached image file count. + int CountImageFiles(string modificationName); + + /// + /// Determines whether an image file path points to an existing file. + /// + /// The image file path to inspect. + /// when the image exists. + bool ImageExists(string? imageFilePath); + + /// + /// Removes an image file when it exists. + /// + /// The image file path to remove. + /// when no image remains at the path. + bool TryDeleteImage(string? imageFilePath); + + /// + /// Replaces cached images for a modification image base name with a selected source image. + /// + /// The image replacement request. + /// A token that cancels the replacement before the next file operation. + /// The destination image file path. + Task ReplaceImageAsync( + ModificationImageReplacementRequest request, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Mods/Models/AdvertisingData.cs b/GenLauncherGO.Core/Mods/Models/AdvertisingData.cs new file mode 100644 index 00000000..b47d55f0 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/AdvertisingData.cs @@ -0,0 +1,36 @@ +using System.Collections.Generic; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Describes one advertising content entry from the remote launcher manifest. +/// +/// +/// This type mirrors the third-party repository manifest schema. Keep these property names compatible with the remote +/// backend and map to cleaner internal models outside this boundary when needed. +/// +public sealed class AdvertisingData +{ + /// + /// Initializes a new instance of the class. + /// + public AdvertisingData() + { + ImagesData = new List(); + } + + /// + /// Gets or sets the advertised modification name using the backend manifest property name. + /// + public string ModName { get; set; } = string.Empty; + + /// + /// Gets or sets the advertised modification manifest link using the backend manifest property name. + /// + public string ModLink { get; set; } = string.Empty; + + /// + /// Gets or sets the advertising image links using the backend manifest property name. + /// + public List ImagesData { get; set; } +} diff --git a/GenLauncherGO.Core/Mods/Models/ColorsInfoString.cs b/GenLauncherGO.Core/Mods/Models/ColorsInfoString.cs new file mode 100644 index 00000000..4e0696c0 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/ColorsInfoString.cs @@ -0,0 +1,122 @@ +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Stores launcher color and image values as serialized strings from remote or local visual configuration. +/// +public sealed class ColorsInfoString +{ + /// + /// Gets or sets the active accent color. + /// + public string GenLauncherActiveColor { get; set; } = string.Empty; + + /// + /// Gets or sets the background image link. + /// + public string GenLauncherBackgroundImageLink { get; set; } = string.Empty; + + /// + /// Gets or sets the primary border color. + /// + public string GenLauncherBorderColor { get; set; } = string.Empty; + + /// + /// Gets or sets the button selection color. + /// + public string GenLauncherButtonSelectionColor { get; set; } = string.Empty; + + /// + /// Gets or sets the dark background color. + /// + public string GenLauncherDarkBackGround { get; set; } = string.Empty; + + /// + /// Gets or sets the dark fill color. + /// + public string GenLauncherDarkFillColor { get; set; } = string.Empty; + + /// + /// Gets or sets the default text color. + /// + public string GenLauncherDefaultTextColor { get; set; } = string.Empty; + + /// + /// Gets or sets the download text color. + /// + public string GenLauncherDownloadTextColor { get; set; } = string.Empty; + + /// + /// Gets or sets the inactive border color. + /// + public string GenLauncherInactiveBorder { get; set; } = string.Empty; + + /// + /// Gets or sets the secondary inactive border color. + /// + public string GenLauncherInactiveBorder2 { get; set; } = string.Empty; + + /// + /// Gets or sets the light background color. + /// + public string GenLauncherLightBackGround { get; set; } = string.Empty; + + /// + /// Gets or sets the first list-box selection color. + /// + public string GenLauncherListBoxSelectionColor1 { get; set; } = string.Empty; + + /// + /// Gets or sets the second list-box selection color. + /// + public string GenLauncherListBoxSelectionColor2 { get; set; } = string.Empty; + + /// + /// Initializes a new instance of the class. + /// + public ColorsInfoString() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The primary border color. + /// The inactive border color. + /// The secondary inactive border color. + /// The active accent color. + /// The dark fill color. + /// The dark background color. + /// The light background color. + /// The default text color. + /// The download text color. + /// The second list-box selection color. + /// The first list-box selection color. + /// The button selection color. + public ColorsInfoString( + string border, + string inactiveBorder, + string inactiveBorder2, + string activeColor, + string darkFill, + string darkBackground, + string lightBackground, + string text, + string text2, + string sColor2, + string sColor1, + string bColor) + { + GenLauncherBorderColor = border; + GenLauncherInactiveBorder = inactiveBorder; + GenLauncherInactiveBorder2 = inactiveBorder2; + GenLauncherActiveColor = activeColor; + GenLauncherDarkFillColor = darkFill; + GenLauncherDarkBackGround = darkBackground; + GenLauncherLightBackGround = lightBackground; + GenLauncherDefaultTextColor = text; + GenLauncherDownloadTextColor = text2; + GenLauncherListBoxSelectionColor2 = sColor2; + GenLauncherListBoxSelectionColor1 = sColor1; + GenLauncherButtonSelectionColor = bColor; + } +} diff --git a/GenLauncherGO.Core/Mods/Models/GameModification.cs b/GenLauncherGO.Core/Mods/Models/GameModification.cs new file mode 100644 index 00000000..6eede445 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/GameModification.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Groups all known versions for one launcher content card. +/// +public class GameModification : ModificationVersion +{ + /// + /// Initializes a new instance of the class. + /// + public GameModification() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The version used to seed the content card. + public GameModification(ModificationVersion version) + { + Name = version.Name; + DependenceName = version.DependenceName; + UpdateModificationData(version); + } + + /// + /// Gets or sets the versions known for this content card. + /// + public List ModificationVersions { get; set; } = new List(); + + /// + /// Gets or sets the content ordering value shown in the launcher list. + /// + public int NumberInList { get; set; } + + /// + /// Adds a version or merges it into an existing version on this content card. + /// + /// The version to add or update. + public void UpdateModificationData(ModificationVersion version) + { + if (ModificationVersions.Contains(version)) + { + ModificationVersion modificationVersion = ModificationVersions[ModificationVersions.IndexOf(version)]; + modificationVersion.UnionModifications(version); + + if (ModificationType == ModificationType.Advertising) + { + UpdateAdvertising(version); + } + } + else + { + ModificationVersions.Add(version); + } + + if (!Installed && version.Installed) + { + Installed = true; + } + + UnionModifications(version); + } + + /// + public override bool Equals(object? obj) + { + if (obj?.GetType() != GetType()) + { + return false; + } + + var modification = (GameModification)obj; + return ModificationType == modification.ModificationType && + String.Equals(Name, modification.Name, StringComparison.OrdinalIgnoreCase) && + String.Equals( + DependenceName ?? string.Empty, + modification.DependenceName ?? string.Empty, + StringComparison.OrdinalIgnoreCase); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine( + ModificationType, + NormalizeIdentityText(Name), + NormalizeIdentityText(DependenceName)); + } + + /// + /// Updates advertising-only metadata from the version. + /// + /// The advertising version. + private void UpdateAdvertising(ModificationVersion version) + { + ModDBLink = version.ModDBLink; + NetworkInfo = version.NetworkInfo; + DiscordLink = version.DiscordLink; + SimpleDownloadLink = version.SimpleDownloadLink; + SupportLink = version.SupportLink; + } + + /// + /// Normalizes content identity text for hash-code generation. + /// + /// The value to normalize. + /// The normalized identity text. + private static string NormalizeIdentityText(string? value) + { + return (value ?? string.Empty).ToUpperInvariant(); + } +} diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentCatalogInitializationRequest.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentCatalogInitializationRequest.cs new file mode 100644 index 00000000..09d2650c --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentCatalogInitializationRequest.cs @@ -0,0 +1,17 @@ +using System; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Describes the runtime context needed to initialize the launcher content catalog. +/// +/// A value indicating whether the remote repository should be queried. +/// The remote top-level manifest URI, or when offline. +/// The resolved launcher paths for the active game installation. +/// The launcher content folder layout. +public sealed record LauncherContentCatalogInitializationRequest( + bool Connected, + Uri? RemoteManifestUri, + LauncherPaths Paths, + LauncherContentLayout Layout); diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentEntryState.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentEntryState.cs new file mode 100644 index 00000000..161e138d --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentEntryState.cs @@ -0,0 +1,45 @@ +using System.Collections.Generic; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Stores local state for one launcher content card without remote manifest metadata. +/// +public sealed class LauncherContentEntryState +{ + /// + /// Gets or sets the content type. + /// + public LauncherContentType ModificationType { get; set; } + + /// + /// Gets or sets the content name. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the parent modification or patch name for add-ons and patches. + /// + public string DependenceName { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether any version of the content is installed locally. + /// + public bool Installed { get; set; } + + /// + /// Gets or sets a value indicating whether the content is selected. + /// + public bool IsSelected { get; set; } + + /// + /// Gets or sets the persisted display order for modification entries. + /// + public int NumberInList { get; set; } + + /// + /// Gets or sets the locally relevant versions for the content. + /// + public List ModificationVersions { get; set; } = + new List(); +} diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentLayout.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentLayout.cs new file mode 100644 index 00000000..77eb4103 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentLayout.cs @@ -0,0 +1,39 @@ +using System; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Describes the launcher content folder names used below the mods directory. +/// +public sealed class LauncherContentLayout +{ + /// + /// Initializes a new instance of the class. + /// + /// The folder name that contains add-ons under a parent content folder. + /// The folder name that contains patches under a parent content folder. + /// + /// Thrown when a folder name is , empty, or whitespace. + /// + public LauncherContentLayout( + string addonsFolderName, + string patchesFolderName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(addonsFolderName); + ArgumentException.ThrowIfNullOrWhiteSpace(patchesFolderName); + + AddonsFolderName = addonsFolderName; + PatchesFolderName = patchesFolderName; + } + + /// + /// Gets the folder name that contains add-ons under a parent content folder. + /// + public string AddonsFolderName { get; } + + /// + /// Gets the folder name that contains patches under a parent content folder. + /// + public string PatchesFolderName { get; } + +} diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentState.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentState.cs new file mode 100644 index 00000000..96cb7cc4 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentState.cs @@ -0,0 +1,24 @@ +using System.Collections.Generic; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Stores compact launcher content state that is safe to persist locally. +/// +public sealed class LauncherContentState +{ + /// + /// Gets or sets local add-on state. + /// + public List Addons { get; set; } = new List(); + + /// + /// Gets or sets local modification state. + /// + public List Modifications { get; set; } = new List(); + + /// + /// Gets or sets local patch state. + /// + public List Patches { get; set; } = new List(); +} diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentType.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentType.cs new file mode 100644 index 00000000..1baa8698 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentType.cs @@ -0,0 +1,27 @@ +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Identifies the launcher content category stored in compact launcher state. +/// +public enum LauncherContentType +{ + /// + /// A game modification. + /// + Mod, + + /// + /// An add-on for a game modification or patch. + /// + Addon, + + /// + /// A patch for a game modification or the original game. + /// + Patch, + + /// + /// Advertising content displayed by the launcher. + /// + Advertising +} diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentVersionState.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentVersionState.cs new file mode 100644 index 00000000..95bdf5d8 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentVersionState.cs @@ -0,0 +1,44 @@ +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Stores local state for one launcher content version without remote manifest metadata. +/// +public sealed class LauncherContentVersionState +{ + /// + /// Gets or sets the content type. + /// + public LauncherContentType ModificationType { get; set; } + + /// + /// Gets or sets the content name. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the version label. + /// + public string Version { get; set; } = string.Empty; + + /// + /// Gets or sets the parent modification or patch name for add-ons and patches. + /// + public string DependenceName { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether the content version is installed locally. + /// + public bool Installed { get; set; } + + /// + /// Gets or sets a value indicating whether the content version is selected. + /// + public bool IsSelected { get; set; } + + /// + /// Gets or sets the local integrity source classification for the installed content version. + /// + public ContentSourceKind ContentSourceKind { get; set; } = ContentSourceKind.UnknownLegacy; +} diff --git a/GenLauncherGO.Core/Mods/Models/LauncherData.cs b/GenLauncherGO.Core/Mods/Models/LauncherData.cs new file mode 100644 index 00000000..a75536bd --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherData.cs @@ -0,0 +1,177 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Stores the active launcher content catalog and local state. +/// +public sealed class LauncherData +{ + /// + /// Gets the known add-on content cards. + /// + public List Addons { get; } = new List(); + + /// + /// Gets the known modification content cards. + /// + public List Modifications { get; } = new List(); + + /// + /// Gets the known patch content cards. + /// + public List Patches { get; } = new List(); + + /// + /// Adds a modification version or merges it into the matching content card. + /// + /// The version to add or update. + public void AddOrUpdate(ModificationVersion modificationVersion) + { + switch (modificationVersion.ModificationType) + { + case ModificationType.Mod: + AddOrUpdateModificationVersion(Modifications, modificationVersion); + break; + case ModificationType.Advertising: + AddOrUpdateModificationVersion(Modifications, modificationVersion); + break; + case ModificationType.Addon: + if (String.IsNullOrEmpty(modificationVersion.DependenceName)) + { + return; + } + + AddOrUpdateModificationVersion(Addons, modificationVersion); + break; + case ModificationType.Patch: + AddOrUpdateModificationVersion(Patches, modificationVersion); + break; + } + } + + /// + /// Deletes a modification version from the matching content card. + /// + /// The version to delete. + public void Delete(ModificationVersion modificationVersion) + { + switch (modificationVersion.ModificationType) + { + case ModificationType.Mod: + DeleteModification(Modifications, modificationVersion); + DeleteDependentContent(modificationVersion, Addons, Patches); + break; + case ModificationType.Addon: + DeleteModification(Addons, modificationVersion); + break; + case ModificationType.Patch: + DeleteModification(Patches, modificationVersion); + DeleteDependentAddons(modificationVersion.Name, Addons); + break; + case ModificationType.Advertising: + DeleteModification(Modifications, modificationVersion); + break; + } + } + + /// + /// Adds or updates a version in a specific content storage list. + /// + /// The target storage list. + /// The version to add or update. + private static void AddOrUpdateModificationVersion( + List modificationStorage, + ModificationVersion modificationVersion) + { + var modification = new GameModification(modificationVersion); + + if (modificationStorage.Contains(modification)) + { + GameModification savedModificationData = modificationStorage[modificationStorage.IndexOf(modification)]; + savedModificationData.UpdateModificationData(modificationVersion); + } + else + { + modificationStorage.Add(modification); + } + } + + /// + /// Deletes a version from a specific content storage list. + /// + /// The target storage list. + /// The version to delete. + private static void DeleteModification( + List modificationStorage, + ModificationVersion modificationVersion) + { + var modification = new GameModification(modificationVersion); + + if (!modificationStorage.Contains(modification)) + { + return; + } + + GameModification savedModificationData = modificationStorage[modificationStorage.IndexOf(modification)]; + + if (savedModificationData.ModificationVersions.Contains(modificationVersion)) + { + savedModificationData.ModificationVersions.Remove(modificationVersion); + } + + if (savedModificationData.ModificationVersions.Count == 0) + { + modificationStorage.Remove(modification); + } + } + + /// + /// Deletes patch and add-on cards that depend on a removed modification. + /// + /// The removed modification version. + /// The add-on storage list. + /// The patch storage list. + private static void DeleteDependentContent( + ModificationVersion modificationVersion, + List addons, + List patches) + { + string dependencyName = modificationVersion.Name ?? string.Empty; + var dependentPatches = patches + .Where(patch => IsDependentOn(patch, dependencyName)) + .ToList(); + + foreach (GameModification patch in dependentPatches) + { + DeleteDependentAddons(patch.Name, addons); + patches.Remove(patch); + } + + DeleteDependentAddons(dependencyName, addons); + } + + /// + /// Deletes add-on cards that depend on the named modification or patch. + /// + /// The removed dependency name. + /// The add-on storage list. + private static void DeleteDependentAddons(string? dependencyName, List addons) + { + addons.RemoveAll(addon => IsDependentOn(addon, dependencyName ?? string.Empty)); + } + + /// + /// Determines whether a content card depends on the named parent. + /// + /// The content card. + /// The dependency name. + /// when the card depends on the named parent. + private static bool IsDependentOn(GameModification modification, string dependencyName) + { + return !String.IsNullOrWhiteSpace(dependencyName) && + String.Equals(modification.DependenceName, dependencyName, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/GenLauncherGO.Core/Mods/Models/ManualModificationImportRequest.cs b/GenLauncherGO.Core/Mods/Models/ManualModificationImportRequest.cs new file mode 100644 index 00000000..39a30013 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/ManualModificationImportRequest.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Describes files selected by the user for import into a launcher-managed content version folder. +/// +/// The source files selected by the user. +/// The launcher-managed content version directory that should receive the files. +public sealed record ManualModificationImportRequest( + IReadOnlyList SourceFilePaths, + string DestinationDirectory); diff --git a/GenLauncherGO.Core/Mods/Models/ModAddonsAndPatches.cs b/GenLauncherGO.Core/Mods/Models/ModAddonsAndPatches.cs new file mode 100644 index 00000000..e2ab42ba --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/ModAddonsAndPatches.cs @@ -0,0 +1,42 @@ +using System.Collections.Generic; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Describes one remote modification manifest and its associated patch and add-on manifest links. +/// +/// +/// This type mirrors the third-party repository manifest schema. Keep these property names compatible with the remote +/// backend and map to cleaner internal models outside this boundary when needed. +/// +public sealed class ModAddonsAndPatches +{ + /// + /// Initializes a new instance of the class. + /// + public ModAddonsAndPatches() + { + ModPatches = new List(); + ModAddons = new List(); + } + + /// + /// Gets or sets the modification name using the backend manifest property name. + /// + public string ModName { get; set; } = string.Empty; + + /// + /// Gets or sets the modification manifest link using the backend manifest property name. + /// + public string ModLink { get; set; } = string.Empty; + + /// + /// Gets or sets the patch manifest links for this modification using the backend manifest property name. + /// + public List ModPatches { get; set; } + + /// + /// Gets or sets the add-on manifest links for this modification using the backend manifest property name. + /// + public List ModAddons { get; set; } +} diff --git a/GenLauncherGO.Core/Mods/Models/ModificationImageReplacementRequest.cs b/GenLauncherGO.Core/Mods/Models/ModificationImageReplacementRequest.cs new file mode 100644 index 00000000..05500c39 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/ModificationImageReplacementRequest.cs @@ -0,0 +1,44 @@ +using System; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Describes a request to replace a cached modification image. +/// +public sealed class ModificationImageReplacementRequest +{ + /// + /// Initializes a new instance of the class. + /// + /// The modification name. + /// The image file base name without extension. + /// The selected source image path. + public ModificationImageReplacementRequest( + string modificationName, + string imageBaseName, + string sourceImagePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(modificationName); + ArgumentException.ThrowIfNullOrWhiteSpace(imageBaseName); + ArgumentException.ThrowIfNullOrWhiteSpace(sourceImagePath); + + ModificationName = modificationName; + ImageBaseName = imageBaseName; + SourceImagePath = sourceImagePath; + } + + /// + /// Gets the modification name. + /// + public string ModificationName { get; } + + /// + /// Gets the image file base name without extension. + /// + public string ImageBaseName { get; } + + /// + /// Gets the selected source image path. + /// + public string SourceImagePath { get; } +} diff --git a/GenLauncherGO.Core/Mods/Models/ModificationReposVersion.cs b/GenLauncherGO.Core/Mods/Models/ModificationReposVersion.cs new file mode 100644 index 00000000..33ee109e --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/ModificationReposVersion.cs @@ -0,0 +1,216 @@ +using System; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Describes a modification version as read from remote repository manifests. +/// +/// +/// This type is deserialized from third-party backend YAML documents. Keep its public property names compatible with +/// the backend schema and put any cleaner internal naming behind mapper services instead of changing this contract. +/// +public class ModificationReposVersion +{ + /// + /// Initializes a new instance of the class. + /// + public ModificationReposVersion() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The content name. + /// The version label. + /// The direct package download link. + /// The card image source link. + public ModificationReposVersion( + string name, + string version, + string? downloadLink = null, + string? imageSource = null) + { + Name = name; + Version = version; + UIImageSourceLink = imageSource ?? string.Empty; + SimpleDownloadLink = downloadLink ?? string.Empty; + } + + /// + /// Initializes a new instance of the class. + /// + /// The content name. + public ModificationReposVersion(string name) + { + Name = name; + } + + /// + /// Gets or sets the modification category as represented by backend manifests. + /// + public ModificationType ModificationType { get; set; } + + /// + /// Gets or sets the content name using the backend manifest property name. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the version label using the backend manifest property name. + /// + public string Version { get; set; } = string.Empty; + + /// + /// Gets or sets the direct package download link using the backend manifest property name. + /// + public string SimpleDownloadLink { get; set; } = string.Empty; + + /// + /// Gets or sets the card image source link using the backend manifest property name. + /// + public string UIImageSourceLink { get; set; } = string.Empty; + + /// + /// Gets or sets the Discord link. + /// + public string DiscordLink { get; set; } = string.Empty; + + /// + /// Gets or sets the ModDB link. + /// + public string ModDBLink { get; set; } = string.Empty; + + /// + /// Gets or sets the news link. + /// + public string NewsLink { get; set; } = string.Empty; + + /// + /// Gets or sets the parent content name for add-ons and patches. + /// + public string DependenceName { get; set; } = string.Empty; + + /// + /// Gets or sets the S3 endpoint URL. + /// + public string S3HostLink { get; set; } = string.Empty; + + /// + /// Gets or sets the S3 bucket name. + /// + public string S3BucketName { get; set; } = string.Empty; + + /// + /// Gets or sets the S3 folder name. + /// + public string S3FolderName { get; set; } = string.Empty; + + /// + /// Gets or sets the S3 public key. + /// + public string S3HostPublicKey { get; set; } = string.Empty; + + /// + /// Gets or sets the S3 secret key. + /// + public string S3HostSecretKey { get; set; } = string.Empty; + + /// + /// Gets or sets network information text. + /// + public string NetworkInfo { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether the content is deprecated. + /// + public bool Deprecated { get; set; } + + /// + /// Gets or sets the support link. + /// + public string SupportLink { get; set; } = string.Empty; + + /// + /// Gets or sets optional launcher color information. + /// + public ColorsInfoString? ColorsInformation { get; set; } + + /// + /// Gets or sets the content source kind used by launch integrity checks. + /// + public ContentSourceKind ContentSourceKind { get; set; } = ContentSourceKind.UnknownLegacy; + + /// + /// Gets the content source kind after applying package metadata precedence. + /// + public ContentSourceKind EffectiveContentSourceKind => + ResolveContentSourceKind( + S3HostLink, + S3BucketName, + S3FolderName, + SimpleDownloadLink, + ContentSourceKind); + + /// + /// Gets a compact user-facing content/version label for diagnostics. + /// + public string DisplayName => String.Join(" ", new[] { Name, Version } + .Where(value => !String.IsNullOrWhiteSpace(value))); + + /// + public override bool Equals(object? obj) + { + if (obj is not ModificationReposVersion modification) + { + return false; + } + + return String.Equals(Name, modification.Name, StringComparison.CurrentCultureIgnoreCase); + } + + /// + public override int GetHashCode() + { + return (Name ?? string.Empty).ToUpperInvariant().GetHashCode(); + } + + /// + public override string? ToString() + { + return Name; + } + + /// + /// Resolves the content source kind from package metadata. + /// + /// The S3 endpoint URL. + /// The S3 bucket name. + /// The S3 folder name. + /// The direct package download link. + /// The source kind to use when no package metadata identifies a managed source. + /// The resolved content source kind. + public static ContentSourceKind ResolveContentSourceKind( + string? s3HostLink, + string? s3BucketName, + string? s3FolderName, + string? simpleDownloadLink, + ContentSourceKind fallbackSourceKind) + { + if (!String.IsNullOrWhiteSpace(s3HostLink) && + !String.IsNullOrWhiteSpace(s3BucketName) && + !String.IsNullOrWhiteSpace(s3FolderName)) + { + return ContentSourceKind.ManagedS3; + } + + if (!String.IsNullOrWhiteSpace(simpleDownloadLink)) + { + return ContentSourceKind.ManagedSingleFile; + } + + return fallbackSourceKind; + } +} diff --git a/GenLauncherGO.Core/Mods/Models/ModificationType.cs b/GenLauncherGO.Core/Mods/Models/ModificationType.cs new file mode 100644 index 00000000..cb56a21e --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/ModificationType.cs @@ -0,0 +1,27 @@ +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Identifies the legacy launcher modification category used by WPF workflows. +/// +public enum ModificationType +{ + /// + /// A game modification. + /// + Mod, + + /// + /// An add-on for a game modification or patch. + /// + Addon, + + /// + /// A patch for a game modification or the original game. + /// + Patch, + + /// + /// Advertising content displayed in the launcher. + /// + Advertising +} diff --git a/GenLauncherGO.Core/Mods/Models/ModificationVersion.cs b/GenLauncherGO.Core/Mods/Models/ModificationVersion.cs new file mode 100644 index 00000000..71d2e3d6 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/ModificationVersion.cs @@ -0,0 +1,239 @@ +using System; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Describes a locally tracked modification version with installation and selection state. +/// +public class ModificationVersion : ModificationReposVersion, IComparable +{ + /// + /// Gets or sets a value indicating whether the version is installed locally. + /// + public bool Installed = false; + + /// + /// Gets or sets a value indicating whether the version is selected. + /// + public bool IsSelected = false; + + /// + /// Initializes a new instance of the class. + /// + public ModificationVersion() + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The remote modification metadata. + public ModificationVersion(ModificationReposVersion modification) + { + Name = modification.Name; + Version = modification.Version; + ModificationType = modification.ModificationType; + DependenceName = modification.DependenceName; + ModDBLink = modification.ModDBLink; + DiscordLink = modification.DiscordLink; + SimpleDownloadLink = modification.SimpleDownloadLink; + UIImageSourceLink = modification.UIImageSourceLink; + NewsLink = modification.NewsLink; + NetworkInfo = modification.NetworkInfo; + S3HostLink = modification.S3HostLink; + S3BucketName = modification.S3BucketName; + S3FolderName = modification.S3FolderName; + S3HostPublicKey = modification.S3HostPublicKey; + S3HostSecretKey = modification.S3HostSecretKey; + Deprecated = modification.Deprecated; + ColorsInformation = modification.ColorsInformation; + SupportLink = modification.SupportLink; + ContentSourceKind = modification.EffectiveContentSourceKind; + } + + /// + public int CompareTo(object? o) + { + if (o is not ModificationVersion mv) + { + throw new InvalidOperationException("Cannot compare 2 objects"); + } + + string thisVersionString = + new string(Version.ToCharArray().Where(n => n >= '0' && n <= '9').ToArray()); + string otherVersionString = + new string(mv.Version.ToCharArray().Where(n => n >= '0' && n <= '9').ToArray()); + + while (thisVersionString.Length > otherVersionString.Length) + { + otherVersionString += '0'; + } + + while (thisVersionString.Length < otherVersionString.Length) + { + thisVersionString += '0'; + } + + if (String.IsNullOrEmpty(thisVersionString)) + { + thisVersionString = "-1"; + } + + if (String.IsNullOrEmpty(otherVersionString)) + { + otherVersionString = "-1"; + } + + int thisVersion = int.Parse(thisVersionString); + int otherVersion = int.Parse(otherVersionString); + + return thisVersion.CompareTo(otherVersion); + } + + /// + public override bool Equals(object? obj) + { + if (obj is not ModificationVersion modificationVersion) + { + return false; + } + + return ModificationType == modificationVersion.ModificationType && + String.Equals(Name, modificationVersion.Name, StringComparison.OrdinalIgnoreCase) && + String.Equals(Version, modificationVersion.Version, StringComparison.OrdinalIgnoreCase) && + String.Equals( + DependenceName ?? string.Empty, + modificationVersion.DependenceName ?? string.Empty, + StringComparison.OrdinalIgnoreCase); + } + + /// + public override int GetHashCode() + { + return HashCode.Combine( + ModificationType, + NormalizeIdentityText(Name), + NormalizeIdentityText(Version), + NormalizeIdentityText(DependenceName)); + } + + /// + /// Merges missing metadata and local state from another version record. + /// + /// The source version record. + public void UnionModifications(ModificationVersion otherModificationVersion) + { + if (otherModificationVersion.IsSelected || IsSelected) + { + IsSelected = true; + } + + if (otherModificationVersion.Installed || Installed) + { + Installed = true; + } + + if (!String.IsNullOrEmpty(otherModificationVersion.SimpleDownloadLink) && + String.IsNullOrEmpty(SimpleDownloadLink)) + { + SimpleDownloadLink = otherModificationVersion.SimpleDownloadLink; + } + + if (otherModificationVersion.ModificationType != ModificationType.Mod && + ModificationType == ModificationType.Mod) + { + ModificationType = otherModificationVersion.ModificationType; + } + + if (!String.IsNullOrEmpty(otherModificationVersion.UIImageSourceLink) && + String.IsNullOrEmpty(UIImageSourceLink)) + { + UIImageSourceLink = otherModificationVersion.UIImageSourceLink; + } + + if (!String.IsNullOrEmpty(otherModificationVersion.DependenceName) && + String.IsNullOrEmpty(DependenceName)) + { + DependenceName = otherModificationVersion.DependenceName; + } + + if (!String.IsNullOrEmpty(otherModificationVersion.NewsLink) && String.IsNullOrEmpty(NewsLink)) + { + NewsLink = otherModificationVersion.NewsLink; + } + + if (!String.IsNullOrEmpty(otherModificationVersion.ModDBLink) && String.IsNullOrEmpty(ModDBLink)) + { + ModDBLink = otherModificationVersion.ModDBLink; + } + + if (!String.IsNullOrEmpty(otherModificationVersion.DiscordLink) && String.IsNullOrEmpty(DiscordLink)) + { + DiscordLink = otherModificationVersion.DiscordLink; + } + + if (!String.IsNullOrEmpty(otherModificationVersion.NetworkInfo) && String.IsNullOrEmpty(NetworkInfo)) + { + NetworkInfo = otherModificationVersion.NetworkInfo; + } + + if (!String.IsNullOrEmpty(otherModificationVersion.SupportLink) && String.IsNullOrEmpty(SupportLink)) + { + SupportLink = otherModificationVersion.SupportLink; + } + + if (!String.IsNullOrEmpty(otherModificationVersion.S3BucketName) && String.IsNullOrEmpty(S3BucketName)) + { + S3BucketName = otherModificationVersion.S3BucketName; + } + + if (!String.IsNullOrEmpty(otherModificationVersion.S3FolderName) && String.IsNullOrEmpty(S3FolderName)) + { + S3FolderName = otherModificationVersion.S3FolderName; + } + + if (!String.IsNullOrEmpty(otherModificationVersion.S3HostLink) && String.IsNullOrEmpty(S3HostLink)) + { + S3HostLink = otherModificationVersion.S3HostLink; + } + + if (!String.IsNullOrEmpty(otherModificationVersion.S3HostPublicKey) && + String.IsNullOrEmpty(S3HostPublicKey)) + { + S3HostPublicKey = otherModificationVersion.S3HostPublicKey; + } + + if (!String.IsNullOrEmpty(otherModificationVersion.S3HostSecretKey) && + String.IsNullOrEmpty(S3HostSecretKey)) + { + S3HostSecretKey = otherModificationVersion.S3HostSecretKey; + } + + Deprecated = otherModificationVersion.Deprecated; + + if (otherModificationVersion.ColorsInformation != null) + { + ColorsInformation = otherModificationVersion.ColorsInformation; + } + + if (ContentSourceKind == ContentSourceKind.UnknownLegacy && + otherModificationVersion.ContentSourceKind != ContentSourceKind.UnknownLegacy) + { + ContentSourceKind = otherModificationVersion.ContentSourceKind; + } + + ContentSourceKind = EffectiveContentSourceKind; + } + + /// + /// Normalizes content identity text for hash-code generation. + /// + /// The value to normalize. + /// The normalized identity text. + private static string NormalizeIdentityText(string? value) + { + return (value ?? string.Empty).ToUpperInvariant(); + } +} diff --git a/GenLauncherGO.Core/Mods/Models/ReposModsData.cs b/GenLauncherGO.Core/Mods/Models/ReposModsData.cs new file mode 100644 index 00000000..00fc5be2 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/ReposModsData.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; + +namespace GenLauncherGO.Core.Mods.Models; + +#pragma warning disable IDE1006 // Remote manifest field names preserve legacy YAML binding names. + +/// +/// Describes the top-level third-party remote launcher repository manifest. +/// +/// +/// This type is a backend compatibility boundary. Its member names intentionally preserve the YAML property names +/// emitted by the remote backend that GenLauncherGO does not control. Do not rename, remove, or "clean up" these +/// members without a deliberate compatibility plan that keeps reading the existing backend schema. +/// +public sealed class ReposModsData +{ + /// + /// Gets the advertising entries from the remote manifest. The property name is part of the backend YAML contract. + /// + public List AdvData { get; set; } = new List(); + + /// + /// Gets the globally available add-on manifest links. This legacy lowercase name is required by the backend YAML. + /// + public List globalAddonsData { get; set; } = new List(); + + /// + /// Gets the modification manifest links and child content references. This legacy lowercase name is required by + /// the backend YAML. + /// + public List modDatas { get; set; } = new List(); + + /// + /// Gets the original-game add-on manifest links. This legacy lowercase name is required by the backend YAML. + /// + public List originalGameAddons { get; set; } = new List(); + + /// + /// Gets the original-game patch manifest links. This legacy lowercase name is required by the backend YAML. + /// + public List originalGamePatches { get; set; } = new List(); + + /// + /// Gets or sets the launcher version advertised by the remote manifest. The property name is part of the backend + /// YAML contract. + /// + public string LauncherVersion { get; set; } = string.Empty; +} + +#pragma warning restore IDE1006 diff --git a/GenLauncherGO.Core/Mods/Services/LauncherContentPathResolver.cs b/GenLauncherGO.Core/Mods/Services/LauncherContentPathResolver.cs new file mode 100644 index 00000000..8df99de3 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Services/LauncherContentPathResolver.cs @@ -0,0 +1,60 @@ +using System; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Mods.Services; + +/// +/// Resolves launcher-owned content paths for tracked modification versions. +/// +public sealed class LauncherContentPathResolver : ILauncherContentPathResolver +{ + /// + public string GetVersionDirectoryPath( + LauncherPaths paths, + LauncherContentLayout layout, + ModificationVersion version) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(version); + + return version.ModificationType switch + { + ModificationType.Addon => ResolvePackagePath( + paths.ModsDirectory, + version.DependenceName, + layout.AddonsFolderName, + version.Name, + version.Version), + ModificationType.Mod => ResolvePackagePath( + paths.ModsDirectory, + version.Name, + version.Version), + ModificationType.Patch => ResolvePackagePath( + paths.ModsDirectory, + version.DependenceName, + layout.PatchesFolderName, + version.Name, + version.Version), + _ => string.Empty + }; + } + + /// + /// Resolves a package path from catalog-provided identity segments. + /// + /// The launcher-owned mods directory. + /// The package identity segments. + /// The normalized package path. + private static string ResolvePackagePath(string modsDirectory, params string?[] segments) + { + string[] safeSegments = segments + .Select((segment, index) => LauncherPaths.NormalizePathSegment(segment, $"segment{index}")) + .ToArray(); + return LauncherPaths.ResolveOwnedPath(modsDirectory, Path.Combine(safeSegments)); + } +} diff --git a/GenLauncherGO.Core/Remote/IRemoteAssetDownloader.cs b/GenLauncherGO.Core/Remote/IRemoteAssetDownloader.cs new file mode 100644 index 00000000..e907b971 --- /dev/null +++ b/GenLauncherGO.Core/Remote/IRemoteAssetDownloader.cs @@ -0,0 +1,22 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Core.Remote; + +/// +/// Downloads remote assets to local files. +/// +public interface IRemoteAssetDownloader +{ + /// + /// Downloads an asset only when the destination file is not already present. + /// + /// The remote asset URI. + /// The local destination path. + /// The token used to cancel the request. + Task DownloadIfMissingAsync( + Uri sourceUri, + string destinationFilePath, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Remote/IRemoteConnectionProbe.cs b/GenLauncherGO.Core/Remote/IRemoteConnectionProbe.cs new file mode 100644 index 00000000..375d11ce --- /dev/null +++ b/GenLauncherGO.Core/Remote/IRemoteConnectionProbe.cs @@ -0,0 +1,19 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Core.Remote; + +/// +/// Checks whether a remote HTTP endpoint can be reached. +/// +public interface IRemoteConnectionProbe +{ + /// + /// Returns whether the endpoint responds successfully. + /// + /// The endpoint URI. + /// The token used to cancel the request. + /// when the endpoint can be reached. + Task CanConnectAsync(Uri endpointUri, CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Remote/IRemoteYamlDocumentReader.cs b/GenLauncherGO.Core/Remote/IRemoteYamlDocumentReader.cs new file mode 100644 index 00000000..fc1aa05f --- /dev/null +++ b/GenLauncherGO.Core/Remote/IRemoteYamlDocumentReader.cs @@ -0,0 +1,20 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Core.Remote; + +/// +/// Reads YAML documents from remote HTTP endpoints. +/// +public interface IRemoteYamlDocumentReader +{ + /// + /// Downloads and deserializes a YAML document. + /// + /// The document type. + /// The document URI. + /// The token used to cancel the request. + /// The deserialized document. + Task ReadYamlAsync(Uri documentUri, CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Settings/Contracts/ILauncherPreferencesService.cs b/GenLauncherGO.Core/Settings/Contracts/ILauncherPreferencesService.cs new file mode 100644 index 00000000..4e19ceff --- /dev/null +++ b/GenLauncherGO.Core/Settings/Contracts/ILauncherPreferencesService.cs @@ -0,0 +1,26 @@ +using System; +using GenLauncherGO.Core.Settings.Models; + +namespace GenLauncherGO.Core.Settings.Contracts; + +/// +/// Provides the current launcher preferences and persists preference updates. +/// +public interface ILauncherPreferencesService +{ + /// + /// Occurs after launcher preferences have changed. + /// + event EventHandler? PreferencesChanged; + + /// + /// Gets the current launcher preferences. + /// + LauncherPreferences Current { get; } + + /// + /// Persists the supplied launcher preferences and publishes the updated state. + /// + /// The preferences to persist. + void Update(LauncherPreferences preferences); +} diff --git a/GenLauncherGO.Core/Settings/Contracts/ILauncherSettingsLinkService.cs b/GenLauncherGO.Core/Settings/Contracts/ILauncherSettingsLinkService.cs new file mode 100644 index 00000000..46f5a8f2 --- /dev/null +++ b/GenLauncherGO.Core/Settings/Contracts/ILauncherSettingsLinkService.cs @@ -0,0 +1,31 @@ +namespace GenLauncherGO.Core.Settings.Contracts; + +/// +/// Opens external resources linked from the launcher settings window. +/// +public interface ILauncherSettingsLinkService +{ + /// + /// Opens the Generals Online Discord server. + /// + /// when the link was opened; otherwise, . + bool TryOpenGeneralsOnlineDiscordLink(); + + /// + /// Opens the launcher log directory. + /// + /// when the link was opened; otherwise, . + bool TryOpenLogsDirectory(); + + /// + /// Opens the GenLauncherGO GitHub repository. + /// + /// when the link was opened; otherwise, . + bool TryOpenGitHubRepository(); + + /// + /// Opens the donation page for the original GenLauncher author. + /// + /// when the link was opened; otherwise, . + bool TryOpenOriginalAuthorDonationLink(); +} diff --git a/GenLauncherGO.Core/Settings/Models/LauncherPreferences.cs b/GenLauncherGO.Core/Settings/Models/LauncherPreferences.cs new file mode 100644 index 00000000..91b82f6e --- /dev/null +++ b/GenLauncherGO.Core/Settings/Models/LauncherPreferences.cs @@ -0,0 +1,47 @@ +namespace GenLauncherGO.Core.Settings.Models; + +/// +/// Captures user preferences that affect launcher behavior and the launcher settings UI. +/// +public sealed record LauncherPreferences +{ + /// + /// Gets the number of launcher starts counted toward periodic advertising refresh. + /// + public int LaunchesCount { get; init; } + + /// + /// Gets a value indicating whether outdated installed mod versions are removed after updates. + /// + public bool AutoDeleteOldVersions { get; init; } + + /// + /// Gets a value indicating whether the launcher window is hidden while the game or World Builder runs. + /// + public bool HideLauncherAfterGameStart { get; init; } + + /// + /// Gets a value indicating whether the launcher UI is forced to English. + /// + public bool UseEnglishLanguage { get; init; } + + /// + /// Gets the executable name for the selected game client. + /// + public string SelectedGameClient { get; init; } = string.Empty; + + /// + /// Gets the executable name for the selected World Builder. + /// + public string SelectedWorldBuilder { get; init; } = string.Empty; + + /// + /// Gets optional command-line arguments passed to the selected game executable. + /// + public string GameArguments { get; init; } = string.Empty; + + /// + /// Gets optional command-line arguments passed to the selected World Builder executable. + /// + public string WorldBuilderArguments { get; init; } = string.Empty; +} diff --git a/GenLauncherGO.Core/Settings/Models/LauncherPreferencesChangedEventArgs.cs b/GenLauncherGO.Core/Settings/Models/LauncherPreferencesChangedEventArgs.cs new file mode 100644 index 00000000..9c22373f --- /dev/null +++ b/GenLauncherGO.Core/Settings/Models/LauncherPreferencesChangedEventArgs.cs @@ -0,0 +1,25 @@ +using System; + +namespace GenLauncherGO.Core.Settings.Models; + +/// +/// Provides the updated launcher preferences for preference-change notifications. +/// +public sealed class LauncherPreferencesChangedEventArgs : EventArgs +{ + /// + /// Initializes a new instance of the class. + /// + /// The current launcher preferences after the change. + public LauncherPreferencesChangedEventArgs(LauncherPreferences preferences) + { + ArgumentNullException.ThrowIfNull(preferences); + + Preferences = preferences; + } + + /// + /// Gets the current launcher preferences after the change. + /// + public LauncherPreferences Preferences { get; } +} diff --git a/GenLauncherGO.Core/Shell/Contracts/ILauncherShellService.cs b/GenLauncherGO.Core/Shell/Contracts/ILauncherShellService.cs new file mode 100644 index 00000000..f5ec3805 --- /dev/null +++ b/GenLauncherGO.Core/Shell/Contracts/ILauncherShellService.cs @@ -0,0 +1,24 @@ +using GenLauncherGO.Core.Shell.Models; + +namespace GenLauncherGO.Core.Shell.Contracts; + +/// +/// Opens launcher-related external targets through the operating system shell. +/// +public interface ILauncherShellService +{ + /// + /// Opens an absolute URI with the operating system shell. + /// + /// The absolute URI to open. + /// The result of the shell-open request. + ShellOpenResult OpenUri(string uri); + + /// + /// Opens a folder with the operating system shell. + /// + /// The folder path to open. + /// Whether the folder must contain at least one file before it can be opened. + /// The result of the shell-open request. + ShellOpenResult OpenFolder(string folderPath, bool requireFiles = false); +} diff --git a/GenLauncherGO.Core/Shell/Models/ShellOpenFailureKind.cs b/GenLauncherGO.Core/Shell/Models/ShellOpenFailureKind.cs new file mode 100644 index 00000000..cdafc45d --- /dev/null +++ b/GenLauncherGO.Core/Shell/Models/ShellOpenFailureKind.cs @@ -0,0 +1,27 @@ +namespace GenLauncherGO.Core.Shell.Models; + +/// +/// Describes why an operating system shell-open request failed. +/// +public enum ShellOpenFailureKind +{ + /// + /// No failure occurred. + /// + None, + + /// + /// The requested shell target was missing. + /// + MissingTarget, + + /// + /// The requested shell target was malformed or unsupported. + /// + InvalidTarget, + + /// + /// The operating system rejected or failed the shell-open request. + /// + LaunchFailed, +} diff --git a/GenLauncherGO.Core/Shell/Models/ShellOpenResult.cs b/GenLauncherGO.Core/Shell/Models/ShellOpenResult.cs new file mode 100644 index 00000000..6064191c --- /dev/null +++ b/GenLauncherGO.Core/Shell/Models/ShellOpenResult.cs @@ -0,0 +1,83 @@ +using System; + +namespace GenLauncherGO.Core.Shell.Models; + +/// +/// Represents the outcome of opening an external target through the operating system shell. +/// +public sealed class ShellOpenResult +{ + /// + /// Initializes a new instance of the class. + /// + /// Whether the target was opened successfully. + /// The failure kind when the target was not opened. + /// The requested shell target. + /// Diagnostic detail for a failed request. + private ShellOpenResult( + bool succeeded, + ShellOpenFailureKind failureKind, + string target, + string? message) + { + Succeeded = succeeded; + FailureKind = failureKind; + Target = target; + Message = message; + } + + /// + /// Gets a value indicating whether the target was opened successfully. + /// + public bool Succeeded { get; } + + /// + /// Gets the failure kind when the target was not opened. + /// + public ShellOpenFailureKind FailureKind { get; } + + /// + /// Gets the target that was requested. + /// + public string Target { get; } + + /// + /// Gets diagnostic detail for a failed shell-open request. + /// + public string? Message { get; } + + /// + /// Creates a successful shell-open result. + /// + /// The target that was opened. + /// A successful shell-open result. + public static ShellOpenResult Success(string target) + { + ArgumentException.ThrowIfNullOrWhiteSpace(target); + + return new ShellOpenResult(true, ShellOpenFailureKind.None, target, null); + } + + /// + /// Creates a failed shell-open result. + /// + /// The kind of failure that occurred. + /// The target that could not be opened. + /// Diagnostic detail for the failure. + /// A failed shell-open result. + public static ShellOpenResult Failure( + ShellOpenFailureKind failureKind, + string target, + string message) + { + if (failureKind == ShellOpenFailureKind.None) + { + throw new ArgumentException("Failure results must include a failure kind.", nameof(failureKind)); + } + + ArgumentException.ThrowIfNullOrWhiteSpace(target); + ArgumentException.ThrowIfNullOrWhiteSpace(message); + + return new ShellOpenResult(false, failureKind, target, message); + } +} diff --git a/GenLauncherGO.Core/Startup/Contracts/ILauncherHostEnvironmentService.cs b/GenLauncherGO.Core/Startup/Contracts/ILauncherHostEnvironmentService.cs new file mode 100644 index 00000000..71320cec --- /dev/null +++ b/GenLauncherGO.Core/Startup/Contracts/ILauncherHostEnvironmentService.cs @@ -0,0 +1,47 @@ +using System; + +namespace GenLauncherGO.Core.Startup.Contracts; + +/// +/// Provides host-process and operating-system operations needed by launcher startup. +/// +public interface ILauncherHostEnvironmentService +{ + /// + /// Brings the first visible window for the current process name to the foreground when possible. + /// + void ActivateCurrentProcessWindow(); + + /// + /// Gets the directory containing the running launcher executable. + /// + /// The executable directory. + string GetExecutableDirectory(); + + /// + /// Returns whether the current process is running with elevated administrator privileges. + /// + /// when the process is elevated. + bool IsCurrentProcessElevated(); + + /// + /// Returns whether a directory is under a protected Program Files location. + /// + /// The directory to inspect. + /// when the directory is protected by Program Files. + bool IsProtectedProgramFilesDirectory(string directory); + + /// + /// Sets the current process working directory. + /// + /// The directory to make current. + void SetCurrentDirectory(string directory); + + /// + /// Attempts to acquire the launcher single-instance guard. + /// + /// The single-instance name. + /// The delay before one retry when another instance already exists. + /// A guard whose acquired state identifies whether this process may continue. + ILauncherSingleInstanceGuard TryAcquireSingleInstance(string instanceName, TimeSpan retryDelay); +} diff --git a/GenLauncherGO.Core/Startup/Contracts/ILauncherSingleInstanceGuard.cs b/GenLauncherGO.Core/Startup/Contracts/ILauncherSingleInstanceGuard.cs new file mode 100644 index 00000000..23c6b966 --- /dev/null +++ b/GenLauncherGO.Core/Startup/Contracts/ILauncherSingleInstanceGuard.cs @@ -0,0 +1,14 @@ +using System; + +namespace GenLauncherGO.Core.Startup.Contracts; + +/// +/// Represents ownership of the launcher single-instance guard. +/// +public interface ILauncherSingleInstanceGuard : IDisposable +{ + /// + /// Gets a value indicating whether the guard was acquired by the current process. + /// + bool IsAcquired { get; } +} diff --git a/GenLauncherGO.Core/Startup/Contracts/ILauncherStartupEnvironmentService.cs b/GenLauncherGO.Core/Startup/Contracts/ILauncherStartupEnvironmentService.cs new file mode 100644 index 00000000..cd54c458 --- /dev/null +++ b/GenLauncherGO.Core/Startup/Contracts/ILauncherStartupEnvironmentService.cs @@ -0,0 +1,21 @@ +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Startup.Models; + +namespace GenLauncherGO.Core.Startup.Contracts; + +/// +/// Reads launcher startup environment details from side-effecting platform sources. +/// +public interface ILauncherStartupEnvironmentService +{ + /// + /// Reads game and customization details needed before the main launcher window opens. + /// + /// The resolved launcher paths for the current session. + /// A token that cancels startup environment discovery. + /// The discovered startup environment. + Task ReadAsync( + LauncherPaths paths, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Startup/ILauncherPathResolver.cs b/GenLauncherGO.Core/Startup/ILauncherPathResolver.cs new file mode 100644 index 00000000..0c5307e7 --- /dev/null +++ b/GenLauncherGO.Core/Startup/ILauncherPathResolver.cs @@ -0,0 +1,21 @@ +namespace GenLauncherGO.Core.Startup; + +/// +/// Resolves and prepares the launcher-owned directories for a GenLauncherGO session. +/// +public interface ILauncherPathResolver +{ + /// + /// Resolves launcher paths from the executable directory. + /// + /// The directory containing the running executable. + /// The resolved paths, or when no supported game directory is found. + LauncherPaths? Resolve(string executableDirectory); + + /// + /// Creates launcher-owned directories and optionally clears temporary files. + /// + /// The resolved launcher paths. + /// Whether to remove existing temporary directory contents. + void PrepareLauncherDirectories(LauncherPaths paths, bool cleanTemporaryDirectory); +} diff --git a/GenLauncherGO.Core/Startup/LauncherPaths.cs b/GenLauncherGO.Core/Startup/LauncherPaths.cs new file mode 100644 index 00000000..ff71e02f --- /dev/null +++ b/GenLauncherGO.Core/Startup/LauncherPaths.cs @@ -0,0 +1,229 @@ +using System; +using System.IO; + +namespace GenLauncherGO.Core.Startup; + +/// +/// Describes the resolved game and launcher-owned directories for one launcher session. +/// +/// The supported game installation directory. +/// The root directory owned by GenLauncherGO. +/// The directory containing transient and recoverable launcher runtime state. +/// The directory containing launcher cache files. +/// The directory containing cached downloaded and user-selected images. +/// The directory containing installed mods, patches, and add-ons. +/// The directory containing launcher log files. +/// The directory containing temporary launcher files. +/// The directory containing active deployment state used for cleanup and recovery. +public sealed record LauncherPaths( + string GameDirectory, + string LauncherDirectory, + string RuntimeDirectory, + string CacheDirectory, + string ImagesDirectory, + string ModsDirectory, + string LogsDirectory, + string TempDirectory, + string DeploymentDirectory) +{ + /// + /// The launcher data file name used by GenLauncherGO. + /// + public const string LauncherDataFileName = "LauncherData.yaml"; + + /// + /// The user preferences file name used by GenLauncherGO. + /// + public const string PreferencesFileName = "LauncherPreferences.yaml"; + + /// + /// The per-entry image cache folder name. + /// + public const string ModificationImagesFolderName = "Images"; + + /// + /// Returns the directory containing launcher-owned runtime state. + /// + public string StateDirectory => Path.Combine(RuntimeDirectory, "State"); + + /// + /// Returns the full launcher data file path. + /// + public string LauncherDataFilePath => Path.Combine(StateDirectory, LauncherDataFileName); + + /// + /// Returns the full launcher user preferences file path. + /// + public string PreferencesFilePath => Path.Combine(LauncherDirectory, PreferencesFileName); + + /// + /// Builds the image cache directory path for a mod, add-on, patch, or advertisement entry. + /// + /// The modification name. + /// The image cache directory path. + /// + /// Thrown when is empty, whitespace, or unsafe for a path segment. + /// + public string GetModificationImagesDirectory(string modificationName) + { + string safeModificationName = NormalizePathSegment(modificationName, nameof(modificationName)); + + return ResolveOwnedPath(ImagesDirectory, safeModificationName); + } + + /// + /// Builds an image cache file path for a mod, add-on, patch, or advertisement entry. + /// + /// The modification name. + /// The cache file name, including its extension. + /// The image cache file path. + /// + /// Thrown when or is empty, whitespace, or + /// unsafe for a path segment. + /// + public string GetModificationImageFilePath(string modificationName, string imageFileName) + { + string safeImageFileName = NormalizePathSegment(imageFileName, nameof(imageFileName)); + + return ResolveOwnedPath(GetModificationImagesDirectory(modificationName), safeImageFileName); + } + + /// + /// Builds a package staging folder path below the launcher temporary directory. + /// + /// The installed package folder path. + /// The temporary package staging folder path. + /// + /// Thrown when is empty, whitespace, or cannot be staged below the + /// launcher temporary package directory. + /// + public string GetPackageTemporaryFolderPath(string installedFolderPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(installedFolderPath); + + string installedFullPath = Path.GetFullPath(installedFolderPath); + string modsFullPath = Path.GetFullPath(ModsDirectory); + string relativePath = Path.GetRelativePath(modsFullPath, installedFullPath); + + if (PathLeavesRoot(relativePath)) + { + relativePath = NormalizePathSegment(Path.GetFileName(installedFullPath), nameof(installedFolderPath)); + } + + string temporaryPackagesRoot = Path.Combine(TempDirectory, "Packages"); + string temporaryPath = Path.GetFullPath(Path.Combine(temporaryPackagesRoot, relativePath)); + if (!IsPathInDirectory(temporaryPath, temporaryPackagesRoot)) + { + throw new ArgumentException( + "The installed package path cannot be staged outside the launcher temporary package folder.", + nameof(installedFolderPath)); + } + + return temporaryPath; + } + + /// + /// Normalizes one user-facing path segment used below launcher-owned folders. + /// + /// The segment value. + /// The parameter name used for validation exceptions. + /// The normalized segment. + /// Thrown when the segment is empty, rooted, reserved, or contains path separators. + internal static string NormalizePathSegment(string? segment, string paramName) + { + if (string.IsNullOrWhiteSpace(segment)) + { + throw new ArgumentException("Path segments must not be empty.", paramName); + } + + string normalizedSegment = segment.Trim(); + if (Path.IsPathRooted(normalizedSegment) || + normalizedSegment.Contains(Path.DirectorySeparatorChar, StringComparison.Ordinal) || + normalizedSegment.Contains(Path.AltDirectorySeparatorChar, StringComparison.Ordinal) || + normalizedSegment.Contains(':', StringComparison.Ordinal) || + normalizedSegment.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0) + { + throw new ArgumentException("Path segments must not contain rooted paths or directory separators.", paramName); + } + + if (string.Equals(normalizedSegment, ".", StringComparison.Ordinal) || + string.Equals(normalizedSegment, "..", StringComparison.Ordinal) || + normalizedSegment.EndsWith(".", StringComparison.Ordinal) || + IsReservedDeviceName(normalizedSegment)) + { + throw new ArgumentException("Path segments must not use reserved file-system names.", paramName); + } + + return normalizedSegment; + } + + /// + /// Resolves a child path and verifies that it remains below the supplied root. + /// + /// The owning root directory. + /// The relative child path. + /// The normalized child path. + /// Thrown when the resolved child path leaves the owning root. + internal static string ResolveOwnedPath(string rootDirectory, string relativePath) + { + string rootPath = Path.GetFullPath(rootDirectory); + string candidatePath = Path.GetFullPath(Path.Combine(rootPath, relativePath)); + if (!IsPathInDirectory(candidatePath, rootPath)) + { + throw new ArgumentException("The resolved path must stay inside the launcher-owned directory."); + } + + return candidatePath; + } + + /// + /// Determines whether a relative path leaves its root. + /// + /// The relative path to inspect. + /// when the relative path leaves the root. + private static bool PathLeavesRoot(string relativePath) + { + return string.Equals(relativePath, "..", StringComparison.Ordinal) || + relativePath.StartsWith(".." + Path.DirectorySeparatorChar, StringComparison.Ordinal) || + relativePath.StartsWith(".." + Path.AltDirectorySeparatorChar, StringComparison.Ordinal) || + Path.IsPathRooted(relativePath); + } + + /// + /// Determines whether a path is equal to or below a directory after full-path normalization. + /// + /// The path to inspect. + /// The expected parent directory. + /// when the directory contains the path. + private static bool IsPathInDirectory(string path, string directory) + { + string normalizedDirectory = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)); + string normalizedPath = Path.GetFullPath(path); + return normalizedPath.Equals(normalizedDirectory, StringComparison.OrdinalIgnoreCase) || + normalizedPath.StartsWith( + normalizedDirectory + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase); + } + + /// + /// Determines whether a path segment is a Windows reserved device name. + /// + /// The segment to inspect. + /// when the segment is reserved. + private static bool IsReservedDeviceName(string segment) + { + string name = segment.Split('.')[0]; + if (string.Equals(name, "CON", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "PRN", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "AUX", StringComparison.OrdinalIgnoreCase) || + string.Equals(name, "NUL", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return name.Length == 4 && + (name.StartsWith("COM", StringComparison.OrdinalIgnoreCase) || + name.StartsWith("LPT", StringComparison.OrdinalIgnoreCase)) && + name[3] is >= '1' and <= '9'; + } +} diff --git a/GenLauncherGO.Core/Startup/Models/LauncherStartupEnvironment.cs b/GenLauncherGO.Core/Startup/Models/LauncherStartupEnvironment.cs new file mode 100644 index 00000000..f3f51e94 --- /dev/null +++ b/GenLauncherGO.Core/Startup/Models/LauncherStartupEnvironment.cs @@ -0,0 +1,14 @@ +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Startup.Models; + +/// +/// Describes startup state discovered from the local launcher environment. +/// +/// The game variant detected in the current game directory. +/// The optional local launcher color override. +/// The optional local launcher background image path. +public sealed record LauncherStartupEnvironment( + SupportedGame ManagedGame, + ColorsInfoString? CustomColors, + string? CustomBackgroundImagePath); diff --git a/GenLauncherGO.Core/Startup/SessionInformation.cs b/GenLauncherGO.Core/Startup/SessionInformation.cs new file mode 100644 index 00000000..d8b5e310 --- /dev/null +++ b/GenLauncherGO.Core/Startup/SessionInformation.cs @@ -0,0 +1,38 @@ +namespace GenLauncherGO.Core.Startup; + +/// +/// Stores launcher session state discovered during startup. +/// +public sealed class SessionInformation +{ + /// + /// Gets or sets a value indicating whether the launcher has network connectivity for remote catalog access. + /// + public bool Connected { get; set; } + + /// + /// Gets or sets the game variant currently managed by this launcher session. + /// + public SupportedGame CurrentlyManagedGame { get; set; } +} + +/// +/// Identifies the supported Command & Conquer game variants GenLauncherGO can manage. +/// +public enum SupportedGame +{ + /// + /// No supported game variant has been detected for this launcher session. + /// + Unknown = 0, + + /// + /// Command & Conquer: Generals - Zero Hour. + /// + ZeroHour = 1, + + /// + /// Command & Conquer: Generals. + /// + Generals = 2 +} diff --git a/GenLauncherGO.Core/Updating/Contracts/IDownloadFileMetadataReader.cs b/GenLauncherGO.Core/Updating/Contracts/IDownloadFileMetadataReader.cs new file mode 100644 index 00000000..b2742811 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Contracts/IDownloadFileMetadataReader.cs @@ -0,0 +1,22 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Core.Updating.Contracts; + +/// +/// Reads metadata for remote downloadable files. +/// +public interface IDownloadFileMetadataReader +{ + /// + /// Reads remote file metadata. + /// + /// The remote download URI. + /// The token used to cancel the request. + /// The remote file metadata. + Task ReadMetadataAsync( + Uri downloadUri, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Updating/Contracts/IFileHashService.cs b/GenLauncherGO.Core/Updating/Contracts/IFileHashService.cs new file mode 100644 index 00000000..3b0cba28 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Contracts/IFileHashService.cs @@ -0,0 +1,18 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Core.Updating.Contracts; + +/// +/// Computes hashes for local files. +/// +public interface IFileHashService +{ + /// + /// Computes an MD5 hash for a local file. + /// + /// The local file path. + /// The token used to cancel the operation. + /// The uppercase hexadecimal MD5 hash. + Task ComputeMd5HashAsync(string filePath, CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Updating/Contracts/IPackageDownloadOperation.cs b/GenLauncherGO.Core/Updating/Contracts/IPackageDownloadOperation.cs new file mode 100644 index 00000000..59959f05 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Contracts/IPackageDownloadOperation.cs @@ -0,0 +1,43 @@ +using System; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Core.Updating.Contracts; + +/// +/// Represents one launcher package download operation. +/// +public interface IPackageDownloadOperation : IDisposable +{ + /// + /// Occurs when package download progress changes. + /// + event Action? ProgressChanged; + + /// + /// Occurs when the package download operation completes. + /// + event Action? Done; + + /// + /// Determines whether the package download can start. + /// + /// The package download readiness result. + PackageDownloadReadiness GetPackageDownloadReadiness(); + + /// + /// Starts the package download operation. + /// + Task StartDownloadModificationAsync(); + + /// + /// Cancels the package download operation. + /// + void CancelDownload(); + + /// + /// Gets the current package download result. + /// + /// The package download result. + PackageDownloadResult GetResult(); +} diff --git a/GenLauncherGO.Core/Updating/Contracts/IPackageDownloadOperationFactory.cs b/GenLauncherGO.Core/Updating/Contracts/IPackageDownloadOperationFactory.cs new file mode 100644 index 00000000..376bc452 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Contracts/IPackageDownloadOperationFactory.cs @@ -0,0 +1,21 @@ +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Core.Updating.Contracts; + +/// +/// Creates launcher package download operations. +/// +public interface IPackageDownloadOperationFactory +{ + /// + /// Creates a package download operation for the requested modification package. + /// + /// The modification package download request. + /// + /// A value indicating whether single-file download should be used even when S3 metadata is present. + /// + /// The package download operation. + IPackageDownloadOperation Create( + ModificationPackageDownloadRequest request, + bool forceSingleFileDownload = false); +} diff --git a/GenLauncherGO.Core/Updating/Contracts/IResumableFileDownloader.cs b/GenLauncherGO.Core/Updating/Contracts/IResumableFileDownloader.cs new file mode 100644 index 00000000..b153e83b --- /dev/null +++ b/GenLauncherGO.Core/Updating/Contracts/IResumableFileDownloader.cs @@ -0,0 +1,24 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Core.Updating.Contracts; + +/// +/// Downloads remote files to disk with support for cancellation and resumable transfers. +/// +public interface IResumableFileDownloader +{ + /// + /// Downloads a remote file to the requested destination. + /// + /// The download request. + /// Optional progress reporter for the transfer. + /// The token used to cancel the transfer. + /// The completed file transfer result. + Task DownloadFileAsync( + DownloadFileRequest request, + IProgress? progress, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Updating/Contracts/ISingleFilePackageUpdater.cs b/GenLauncherGO.Core/Updating/Contracts/ISingleFilePackageUpdater.cs new file mode 100644 index 00000000..8cfa27dd --- /dev/null +++ b/GenLauncherGO.Core/Updating/Contracts/ISingleFilePackageUpdater.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Core.Updating.Contracts; + +/// +/// Updates a package from a single remote file. +/// +public interface ISingleFilePackageUpdater +{ + /// + /// Downloads and installs the requested package. + /// + /// The update request. + /// Optional progress reporter. + /// The token used to cancel the update. + Task UpdateAsync( + SingleFilePackageUpdateRequest request, + IProgress? progress, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Updating/Contracts/ISystemClockService.cs b/GenLauncherGO.Core/Updating/Contracts/ISystemClockService.cs new file mode 100644 index 00000000..1916576b --- /dev/null +++ b/GenLauncherGO.Core/Updating/Contracts/ISystemClockService.cs @@ -0,0 +1,19 @@ +namespace GenLauncherGO.Core.Updating.Contracts; + +/// +/// Provides system clock checks and synchronization used by update workflows. +/// +public interface ISystemClockService +{ + /// + /// Determines whether the local system clock appears too far from network time for signed download requests. + /// + /// when the system clock appears out of sync. + bool IsSystemTimeOutOfSync(); + + /// + /// Attempts to synchronize the local system clock with network time. + /// + /// when the system clock update call succeeds. + bool TrySynchronizeSystemTimeWithNetworkTime(); +} diff --git a/GenLauncherGO.Core/Updating/Models/DownloadFileMetadata.cs b/GenLauncherGO.Core/Updating/Models/DownloadFileMetadata.cs new file mode 100644 index 00000000..a4a7272e --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/DownloadFileMetadata.cs @@ -0,0 +1,14 @@ +using System; + +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Describes metadata for a remote downloadable file. +/// +/// The resolved download URI. +/// The remote file name. +/// The expected file size when known. +public sealed record DownloadFileMetadata( + Uri DownloadUri, + string FileName, + long? TotalBytes); diff --git a/GenLauncherGO.Core/Updating/Models/DownloadFileRequest.cs b/GenLauncherGO.Core/Updating/Models/DownloadFileRequest.cs new file mode 100644 index 00000000..5e0acf8a --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/DownloadFileRequest.cs @@ -0,0 +1,16 @@ +using System; + +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Describes a file transfer requested by an update workflow. +/// +/// The remote file URI to download. +/// The local file path that receives the downloaded content. +/// The expected final file size when the catalog provides one. +/// A value indicating whether an existing partial file should be resumed. +public sealed record DownloadFileRequest( + Uri SourceUri, + string DestinationFilePath, + long? ExpectedBytes = null, + bool Resume = true); diff --git a/GenLauncherGO.Core/Updating/Models/DownloadFileResult.cs b/GenLauncherGO.Core/Updating/Models/DownloadFileResult.cs new file mode 100644 index 00000000..97cc5d30 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/DownloadFileResult.cs @@ -0,0 +1,12 @@ +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Describes the outcome of a completed file transfer. +/// +/// The local file path that contains the completed download. +/// The final number of bytes written to the file. +/// A value indicating whether the transfer resumed from existing local content. +public sealed record DownloadFileResult( + string FilePath, + long BytesWritten, + bool Resumed); diff --git a/GenLauncherGO.Core/Updating/Models/DownloadProgress.cs b/GenLauncherGO.Core/Updating/Models/DownloadProgress.cs new file mode 100644 index 00000000..8991a531 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/DownloadProgress.cs @@ -0,0 +1,12 @@ +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Represents file download progress in bytes. +/// +/// The expected total byte count when known. +/// The number of bytes present locally for the file. +/// The completed percentage when a total byte count is known. +public sealed record DownloadProgress( + long? TotalBytes, + long BytesDownloaded, + double? ProgressPercentage); diff --git a/GenLauncherGO.Core/Updating/Models/ModificationPackageDownloadRequest.cs b/GenLauncherGO.Core/Updating/Models/ModificationPackageDownloadRequest.cs new file mode 100644 index 00000000..24a5dc2e --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/ModificationPackageDownloadRequest.cs @@ -0,0 +1,12 @@ +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Describes a launcher modification package download request. +/// +/// The modification that owns the package. +/// The version that should be downloaded. +public sealed record ModificationPackageDownloadRequest( + GameModification Modification, + ModificationVersion LatestVersion); diff --git a/GenLauncherGO.Core/Updating/Models/PackageDownloadReadiness.cs b/GenLauncherGO.Core/Updating/Models/PackageDownloadReadiness.cs new file mode 100644 index 00000000..8e903ba8 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/PackageDownloadReadiness.cs @@ -0,0 +1,17 @@ +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Describes whether a package download can start. +/// +public sealed record PackageDownloadReadiness +{ + /// + /// Gets a value indicating whether the download can start. + /// + public bool ReadyToDownload { get; init; } + + /// + /// Gets the reason the download cannot start. + /// + public PackageDownloadReadinessError Error { get; init; } +} diff --git a/GenLauncherGO.Core/Updating/Models/PackageDownloadReadinessError.cs b/GenLauncherGO.Core/Updating/Models/PackageDownloadReadinessError.cs new file mode 100644 index 00000000..936a13d9 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/PackageDownloadReadinessError.cs @@ -0,0 +1,17 @@ +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Identifies a package download readiness failure. +/// +public enum PackageDownloadReadinessError +{ + /// + /// The readiness failure is not known. + /// + Unknown = 0, + + /// + /// The system clock is too far out of sync for the remote package source. + /// + TimeOutOfSync = 1, +} diff --git a/GenLauncherGO.Core/Updating/Models/PackageDownloadResult.cs b/GenLauncherGO.Core/Updating/Models/PackageDownloadResult.cs new file mode 100644 index 00000000..dda91c00 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/PackageDownloadResult.cs @@ -0,0 +1,27 @@ +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Describes the outcome of a launcher package download. +/// +public sealed record PackageDownloadResult +{ + /// + /// Gets a value indicating whether the download failed unexpectedly. + /// + public bool Crashed { get; init; } + + /// + /// Gets a value indicating whether the download was canceled by the launcher. + /// + public bool Canceled { get; init; } + + /// + /// Gets a value indicating whether the download timed out. + /// + public bool TimedOut { get; init; } + + /// + /// Gets the diagnostic or user-facing completion message. + /// + public string Message { get; init; } = string.Empty; +} diff --git a/GenLauncherGO.Core/Updating/Models/PackageUpdateProgress.cs b/GenLauncherGO.Core/Updating/Models/PackageUpdateProgress.cs new file mode 100644 index 00000000..c46d1ed8 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/PackageUpdateProgress.cs @@ -0,0 +1,20 @@ +using System; + +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Reports package update progress. +/// +/// The expected total bytes when known. +/// The number of local bytes completed. +/// The completed percentage when total bytes are known. +/// The current file name when available. +/// The aggregate download speed in bytes per second when known. +/// The estimated remaining download time when known. +public sealed record PackageUpdateProgress( + long? TotalBytes, + long BytesRead, + double? ProgressPercentage, + string? FileName, + double? DownloadSpeedBytesPerSecond = null, + TimeSpan? EstimatedTimeRemaining = null); diff --git a/GenLauncherGO.Core/Updating/Models/RemoteFileManifestEntry.cs b/GenLauncherGO.Core/Updating/Models/RemoteFileManifestEntry.cs new file mode 100644 index 00000000..c41d307e --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/RemoteFileManifestEntry.cs @@ -0,0 +1,12 @@ +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Describes a file advertised by a remote update manifest. +/// +/// The relative file name from the manifest. +/// The expected file hash. +/// The expected file size. +public sealed record RemoteFileManifestEntry( + string FileName, + string Hash, + ulong Size); diff --git a/GenLauncherGO.Core/Updating/Models/SingleFilePackageUpdateRequest.cs b/GenLauncherGO.Core/Updating/Models/SingleFilePackageUpdateRequest.cs new file mode 100644 index 00000000..946fa76c --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/SingleFilePackageUpdateRequest.cs @@ -0,0 +1,14 @@ +using System; + +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Describes a single-file package update. +/// +/// The remote file URI. +/// The temporary folder used during update. +/// The final installed folder path. +public sealed record SingleFilePackageUpdateRequest( + Uri SourceUri, + string TemporaryFolderPath, + string InstalledFolderPath); diff --git a/GenLauncherGO.Infrastructure/AGENTS.md b/GenLauncherGO.Infrastructure/AGENTS.md new file mode 100644 index 00000000..4dddbcfe --- /dev/null +++ b/GenLauncherGO.Infrastructure/AGENTS.md @@ -0,0 +1,35 @@ +# GenLauncherGO.Infrastructure Agent Guidelines + +`GenLauncherGO.Infrastructure/` owns concrete adapters and third-party/package-dependent code. + +## Do + +- Implement contracts from `GenLauncherGO.Core/`. +- Keep file-system, process, symbolic-link, archive, network, S3, hashing, and persistence side effects in + Infrastructure. +- Use `ILogger` for diagnostics around real-world side effects. +- Treat logging as required for file-system mutation, process launching, symbolic-link work, archive extraction, network + calls, S3 access, persistence, update/install workflows, and cleanup failures. +- Preserve enough error context for UI and diagnostics through logging or typed results. +- Use temporary directories for tests that exercise file-system behavior. +- Add XML documentation for every production type and member, regardless of accessibility, and call out side effects. +- Prefer Serilog wired through `Microsoft.Extensions.Logging` for runtime logging. +- Use `GenLauncherGO.Infrastructure.Logging.AddGenLauncherGoLogging(...)` when UI startup composes rolling file + logging. +- Keep rolling file logs bounded; the default retention is 14 files. +- Prefer feature folders such as `Common/`, `Archives/`, `Launching/`, `Logging/`, `Mods/`, `Settings/`, `Startup/`, and + `Updating/`. +- Keep an Infrastructure feature folder flat only while it has one responsibility and a small number of files. +- When an Infrastructure feature contains registration, services, and adapter helpers, or grows beyond roughly 6 + production files, split it inside the feature: `Services/` for concrete implementations, `Composition/` for dependency + injection registration, `Clients/` for external-service clients, `Options/` for configuration shapes, and `Support/` + for narrow platform or file-system helpers. +- Keep namespaces aligned with those layer folders, such as `GenLauncherGO.Infrastructure.Launching.Services` and + `GenLauncherGO.Infrastructure.Launching.Support`. + +## Avoid + +- Do not drive UI workflows or contain WPF presentation logic. +- Do not swallow exceptions silently. +- Do not log secrets, access tokens, local credentials, or unnecessary full user paths. +- Do not require a user's real Generals or Zero Hour install in automated tests. diff --git a/GenLauncherGO.Infrastructure/Archives/ArchiveExtractor.cs b/GenLauncherGO.Infrastructure/Archives/ArchiveExtractor.cs new file mode 100644 index 00000000..9a4f500d --- /dev/null +++ b/GenLauncherGO.Infrastructure/Archives/ArchiveExtractor.cs @@ -0,0 +1,127 @@ +using System; +using System.IO; +using System.Threading; +using GenLauncherGO.Core.Archives; +using GenLauncherGO.Infrastructure.Common; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using SharpCompress.Archives; +using SharpCompress.Common; +using SharpCompress.Readers; + +namespace GenLauncherGO.Infrastructure.Archives; + +/// +/// Extracts archive files using the configured infrastructure archive library, creating destination directories, +/// overwriting extracted files, and optionally renaming extracted .big entries to .gib. +/// +public sealed class ArchiveExtractor : IArchiveExtractor +{ + /// + /// Logs archive extraction diagnostics without including full local user paths. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger used to record archive extraction diagnostics. + public ArchiveExtractor(ILogger? logger = null) + { + _logger = logger ?? NullLogger.Instance; + } + + /// + public void ExtractToDirectory( + string archiveFilePath, + string destinationDirectory, + ArchiveExtractionOptions? options = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(archiveFilePath); + ArgumentException.ThrowIfNullOrWhiteSpace(destinationDirectory); + + string destinationRoot = Path.GetFullPath(destinationDirectory); + Directory.CreateDirectory(destinationRoot); + + _logger.LogInformation( + "Extracting archive {ArchiveFilePath} to {DestinationDirectory}. Convert .big files to .gib: {ConvertBigFilesToGib}", + Path.GetFileName(archiveFilePath), + Path.GetFileName(destinationRoot), + options?.ConvertBigFilesToGib == true); + + using FileStream archiveStream = File.OpenRead(archiveFilePath); + using IArchive archive = ArchiveFactory.OpenArchive( + archiveStream, + new ReaderOptions { LeaveStreamOpen = false }); + + int extractedEntryCount = 0; + foreach (IArchiveEntry entry in archive.Entries) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (entry.IsDirectory) + { + continue; + } + + string entryPath = GetDestinationPath(destinationRoot, entry.Key, options); + string? entryDirectory = Path.GetDirectoryName(entryPath); + if (!string.IsNullOrWhiteSpace(entryDirectory)) + { + Directory.CreateDirectory(entryDirectory); + } + + entry.WriteToFile(entryPath, new ExtractionOptions + { + Overwrite = true, + PreserveFileTime = true + }); + + extractedEntryCount++; + } + + _logger.LogInformation( + "Extracted {EntryCount} archive entries to {DestinationDirectory}", + extractedEntryCount, + Path.GetFileName(destinationRoot)); + } + + /// + /// Resolves the destination path for an archive entry and rejects paths outside the extraction root. + /// + /// The fully qualified destination root directory. + /// The archive entry key supplied by the archive reader. + /// Optional extraction behavior that can alter the output file extension. + /// The fully qualified destination path for the archive entry. + /// + /// Thrown when the archive entry has no usable file name or would extract outside the destination directory. + /// + private static string GetDestinationPath( + string destinationRoot, + string? entryKey, + ArchiveExtractionOptions? options) + { + if (string.IsNullOrWhiteSpace(entryKey)) + { + throw new InvalidDataException("Archive entry is missing a file name."); + } + + string normalizedEntryKey = entryKey.Replace('\\', Path.DirectorySeparatorChar) + .Replace('/', Path.DirectorySeparatorChar); + + string destinationPath = Path.GetFullPath(Path.Combine(destinationRoot, normalizedEntryKey)); + if (options?.ConvertBigFilesToGib == true && + string.Equals(Path.GetExtension(destinationPath), ".big", StringComparison.OrdinalIgnoreCase)) + { + destinationPath = Path.ChangeExtension(destinationPath, ".gib"); + } + + if (!FileSystemPathSafety.IsPathInDirectory(destinationPath, destinationRoot)) + { + throw new InvalidDataException($"Archive entry '{entryKey}' would extract outside the destination folder."); + } + + return destinationPath; + } +} diff --git a/GenLauncherGO.Infrastructure/Archives/ArchiveServiceCollectionExtensions.cs b/GenLauncherGO.Infrastructure/Archives/ArchiveServiceCollectionExtensions.cs new file mode 100644 index 00000000..d54e6996 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Archives/ArchiveServiceCollectionExtensions.cs @@ -0,0 +1,28 @@ +using System; +using GenLauncherGO.Core.Archives; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Infrastructure.Archives; + +/// +/// Provides dependency-injection registration helpers for archive infrastructure services. +/// +public static class ArchiveServiceCollectionExtensions +{ + /// + /// Registers archive extraction services used by GenLauncherGO workflows. + /// + /// The service collection used by the application composition root. + /// The same service collection so additional registrations can be chained. + /// + /// Thrown when is . + /// + public static IServiceCollection AddGenLauncherGoArchives(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddSingleton(); + + return services; + } +} diff --git a/GenLauncherGO.Infrastructure/Common/FileSystemPathSafety.cs b/GenLauncherGO.Infrastructure/Common/FileSystemPathSafety.cs new file mode 100644 index 00000000..7cb380a7 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Common/FileSystemPathSafety.cs @@ -0,0 +1,191 @@ +using System; +using System.IO; + +namespace GenLauncherGO.Infrastructure.Common; + +/// +/// Provides shared filesystem path-safety checks for infrastructure services. +/// +internal static class FileSystemPathSafety +{ + /// + /// Determines whether a path is inside a directory after full-path normalization. + /// + /// The path to test. + /// The containing directory. + /// when the path is the directory or a child of it; otherwise, . + public static bool IsPathInDirectory(string path, string directory) + { + string normalizedDirectory = Path.TrimEndingDirectorySeparator(Path.GetFullPath(directory)); + string normalizedPath = Path.GetFullPath(path); + return normalizedPath.Equals(normalizedDirectory, StringComparison.OrdinalIgnoreCase) || + normalizedPath.StartsWith( + normalizedDirectory + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase); + } + + /// + /// Resolves a candidate path and verifies that it stays within an owned root without traversing existing links. + /// + /// The owned root directory. + /// The candidate path. + /// The exception message used when the candidate leaves the owned root. + /// The exception message used when an existing path segment is a reparse point. + /// The normalized candidate path. + public static string ResolveOwnedSubpath( + string ownedRoot, + string candidatePath, + string outsideRootMessage, + string linkedPathMessage) + { + string normalizedRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(ownedRoot)); + string normalizedCandidate = Path.GetFullPath(candidatePath); + if (!IsPathInDirectory(normalizedCandidate, normalizedRoot)) + { + throw new InvalidDataException(outsideRootMessage); + } + + EnsureExistingPathChainHasNoReparsePoints( + normalizedRoot, + "An owned root path must be rooted.", + linkedPathMessage); + EnsureExistingPathChainHasNoReparsePoints( + normalizedCandidate, + "An owned candidate path must be rooted.", + linkedPathMessage); + + return normalizedCandidate; + } + + /// + /// Rejects paths whose existing filesystem chain contains a reparse point. + /// + /// The path to inspect before mutation. + /// The exception message used when the path is not rooted. + /// The exception message used when an existing path segment is a reparse point. + public static void EnsureExistingPathChainHasNoReparsePoints( + string path, + string unrootedPathMessage, + string linkedPathMessage) + { + if (ExistingPathChainContainsReparsePoint(path, unrootedPathMessage)) + { + throw new InvalidDataException(linkedPathMessage); + } + } + + /// + /// Rejects a directory tree whose root or child entries contain a reparse point. + /// + /// The directory tree root to inspect before mutation. + /// The exception message used when a reparse point is found. + public static void EnsureDirectoryTreeHasNoReparsePoints( + string directoryPath, + string linkedPathMessage) + { + string rootPath = Path.GetFullPath(directoryPath); + if (IsReparsePoint(rootPath)) + { + throw new InvalidDataException(linkedPathMessage); + } + + EnsureDirectoryChildrenHaveNoReparsePoints(rootPath, linkedPathMessage); + } + + /// + /// Normalizes a relative path to slash separators for persisted metadata and comparisons. + /// + /// The relative path. + /// The slash-separated normalized path. + public static string NormalizeRelativePath(string path) + { + return path.Replace('\\', '/').Trim('/'); + } + + /// + /// Determines whether an existing path chain contains a reparse point. + /// + /// The path to inspect. + /// The exception message used when the path is not rooted. + /// when an existing path segment is a reparse point; otherwise, . + public static bool ExistingPathChainContainsReparsePoint(string path, string unrootedPathMessage) + { + string fullPath = Path.GetFullPath(path); + string root = Path.GetPathRoot(fullPath) + ?? throw new InvalidDataException(unrootedPathMessage); + string relativePath = Path.GetRelativePath(root, fullPath); + if (relativePath == ".") + { + return false; + } + + string currentPath = root; + string[] segments = relativePath.Split( + new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, + StringSplitOptions.RemoveEmptyEntries); + foreach (string segment in segments) + { + currentPath = Path.Combine(currentPath, segment); + if (!Directory.Exists(currentPath) && !File.Exists(currentPath)) + { + return false; + } + + if (IsReparsePoint(currentPath)) + { + return true; + } + } + + return false; + } + + /// + /// Determines whether a filesystem entry is a reparse point. + /// + /// The filesystem entry path. + /// when the entry is a reparse point; otherwise, . + public static bool IsReparsePoint(string path) + { + return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0; + } + + /// + /// Creates recursive enumeration options that never traverse reparse points. + /// + /// The safe recursive enumeration options. + public static EnumerationOptions CreateRecursiveNoLinksOptions() + { + return new EnumerationOptions + { + AttributesToSkip = FileAttributes.ReparsePoint, + IgnoreInaccessible = false, + RecurseSubdirectories = true, + ReturnSpecialDirectories = false, + }; + } + + /// + /// Recursively rejects child reparse points without traversing through them. + /// + /// The real directory whose children will be inspected. + /// The exception message used when a reparse point is found. + private static void EnsureDirectoryChildrenHaveNoReparsePoints( + string directoryPath, + string linkedPathMessage) + { + foreach (string entryPath in Directory.EnumerateFileSystemEntries(directoryPath)) + { + FileAttributes attributes = File.GetAttributes(entryPath); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidDataException(linkedPathMessage); + } + + if ((attributes & FileAttributes.Directory) != 0) + { + EnsureDirectoryChildrenHaveNoReparsePoints(entryPath, linkedPathMessage); + } + } + } +} diff --git a/GenLauncherGO.Infrastructure/GenLauncherGO.Infrastructure.csproj b/GenLauncherGO.Infrastructure/GenLauncherGO.Infrastructure.csproj new file mode 100644 index 00000000..8bb9e2a1 --- /dev/null +++ b/GenLauncherGO.Infrastructure/GenLauncherGO.Infrastructure.csproj @@ -0,0 +1,24 @@ + + + net10.0-windows + disable + enable + 14.0 + + + + + + + + + + + + + + + + + + diff --git a/GenLauncherGO.Infrastructure/Integrity/Composition/IntegrityServiceCollectionExtensions.cs b/GenLauncherGO.Infrastructure/Integrity/Composition/IntegrityServiceCollectionExtensions.cs new file mode 100644 index 00000000..52b007b7 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Integrity/Composition/IntegrityServiceCollectionExtensions.cs @@ -0,0 +1,33 @@ +using System; +using GenLauncherGO.Core.Integrity.Contracts; +using GenLauncherGO.Infrastructure.Integrity.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Infrastructure.Integrity.Composition; + +/// +/// Provides dependency-injection registration helpers for content-integrity infrastructure. +/// +public static class IntegrityServiceCollectionExtensions +{ + /// + /// Registers the filesystem content-integrity service with a persisted snapshot directory. + /// + /// The service collection to update. + /// The directory used to persist trusted snapshots. + /// The same service collection. + public static IServiceCollection AddGenLauncherGoIntegrity( + this IServiceCollection services, + string snapshotDirectory) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(snapshotDirectory); + + services.AddSingleton(provider => + new FileSystemContentIntegrityService( + snapshotDirectory, + provider + .GetRequiredService>())); + return services; + } +} diff --git a/GenLauncherGO.Infrastructure/Integrity/Services/FileSystemContentIntegrityService.cs b/GenLauncherGO.Infrastructure/Integrity/Services/FileSystemContentIntegrityService.cs new file mode 100644 index 00000000..97711b71 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Integrity/Services/FileSystemContentIntegrityService.cs @@ -0,0 +1,588 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Contracts; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Integrity.Support; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Integrity.Services; + +/// +/// Verifies launcher-owned content with SHA-256 snapshots and applies confirmed managed-content cleanup. +/// +public sealed class FileSystemContentIntegrityService : IContentIntegrityService +{ + /// + /// Receives integrity verification and mutation diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Stores trusted content snapshots. + /// + private readonly ContentIntegritySnapshotStore _snapshotStore; + + /// + /// Initializes a new instance of the class. + /// + /// The directory used to persist trusted snapshots. + /// The logger used for verification and mutation diagnostics. + public FileSystemContentIntegrityService( + string snapshotDirectory, + ILogger logger) + { + ArgumentException.ThrowIfNullOrWhiteSpace(snapshotDirectory); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _snapshotStore = new ContentIntegritySnapshotStore(snapshotDirectory, _logger); + } + + /// + public async Task VerifyAsync( + IReadOnlyList targets, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(targets); + + if (targets.Count > 0) + { + _logger.LogInformation( + "Starting content integrity verification for {TargetCount} target(s).", + targets.Count); + } + else + { + _logger.LogDebug("Skipped content integrity verification because no targets were supplied."); + } + + List issues = new(); + foreach (ContentIntegrityTarget target in targets) + { + cancellationToken.ThrowIfCancellationRequested(); + + ContentIntegritySnapshotDocument? snapshot; + try + { + snapshot = await _snapshotStore.ReadSnapshotAsync(target.Id, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or JsonException or InvalidDataException) + { + _logger.LogError( + exception, + "Failed to read integrity snapshot for {TargetName}.", + target.DisplayName); + issues.Add(CreateIssue( + target, + IntegrityIssueKind.VerificationError, + IntegrityIssueAction.Block, + ".", + exception.Message)); + continue; + } + + if (snapshot is null || snapshot.SourceKind != target.SourceKind) + { + _logger.LogWarning( + "Content integrity target {TargetName} is untracked or has changed source kind. Current source kind: {SourceKind}.", + target.DisplayName, + target.SourceKind); + issues.Add(CreateIssue( + target, + IntegrityIssueKind.Untracked, + GetUntrackedAction(target.SourceKind), + ".")); + + try + { + ContentIntegrityScanResult untrackedScan = + await ContentIntegrityScanner.ScanAsync(target, cancellationToken).ConfigureAwait(false); + AddScanSafetyIssues(target, untrackedScan, issues); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or InvalidDataException) + { + _logger.LogError( + exception, + "Failed to verify untracked content safety for {TargetName}.", + target.DisplayName); + issues.Add(CreateIssue( + target, + IntegrityIssueKind.VerificationError, + IntegrityIssueAction.Block, + ".", + exception.Message)); + } + + continue; + } + + try + { + ContentIntegrityScanResult scan = + await ContentIntegrityScanner.ScanAsync(target, cancellationToken).ConfigureAwait(false); + AddScanIssues(target, snapshot, scan, issues); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or InvalidDataException) + { + _logger.LogError( + exception, + "Failed to verify content for {TargetName}.", + target.DisplayName); + issues.Add(CreateIssue( + target, + IntegrityIssueKind.VerificationError, + IntegrityIssueAction.Block, + ".", + exception.Message)); + } + } + + if (targets.Count > 0) + { + _logger.LogInformation( + "Completed content integrity verification for {TargetCount} target(s); issues: {IssueCount}.", + targets.Count, + issues.Count); + } + + return new ContentIntegrityReport(issues); + } + + /// + public async Task MatchesExpectedFileSetAsync( + ContentIntegrityTarget target, + IReadOnlySet expectedRelativePaths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(expectedRelativePaths); + + ContentIntegrityScanResult scan = + await ContentIntegrityScanner.ScanAsync(target, cancellationToken).ConfigureAwait(false); + bool matches = ContentIntegrityScanner.MatchesExpectedFileSet(scan, expectedRelativePaths); + if (!matches) + { + _logger.LogWarning( + "Integrity scan for {TargetName} did not match the expected file set. Expected: {ExpectedFileCount}; actual: {ActualFileCount}; unsafe links: {UnsafeLinkCount}; errors: {ErrorCount}.", + target.DisplayName, + expectedRelativePaths.Count, + scan.Files.Count, + scan.UnsafeLinks.Count, + scan.Errors.Count); + } + + return matches; + } + + /// + public async Task CaptureSnapshotIfMatchesExpectedFileSetAsync( + ContentIntegrityTarget target, + IReadOnlySet expectedRelativePaths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(expectedRelativePaths); + + ContentIntegrityScanResult scan = + await ContentIntegrityScanner.ScanAsync(target, cancellationToken).ConfigureAwait(false); + if (!ContentIntegrityScanner.MatchesExpectedFileSet(scan, expectedRelativePaths)) + { + _logger.LogWarning( + "Skipped integrity snapshot capture for {TargetName} because the scanned file set did not match the expected package manifest.", + target.DisplayName); + return false; + } + + await _snapshotStore.WriteSnapshotAsync(target, scan, cancellationToken).ConfigureAwait(false); + return true; + } + + /// + public async Task CaptureSnapshotAsync( + ContentIntegrityTarget target, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(target); + + ContentIntegrityScanResult scan = + await ContentIntegrityScanner.ScanAsync(target, cancellationToken).ConfigureAwait(false); + if (scan.UnsafeLinks.Count > 0 || scan.Errors.Count > 0) + { + _logger.LogWarning( + "Blocked integrity snapshot capture for {TargetName}; unsafe links: {UnsafeLinkCount}; errors: {ErrorCount}.", + target.DisplayName, + scan.UnsafeLinks.Count, + scan.Errors.Count); + throw new IOException("Content containing unsafe links or unreadable entries cannot be trusted."); + } + + await _snapshotStore.WriteSnapshotAsync(target, scan, cancellationToken).ConfigureAwait(false); + } + + /// + public Task ApplyCleanupAsync( + ContentIntegrityReport report, + IReadOnlyList targets, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(report); + ArgumentNullException.ThrowIfNull(targets); + + var targetIndex = + targets.ToDictionary(target => target.Id, StringComparer.Ordinal); + int deleteIssueCount = report.Issues.Count(issue => issue.Action == IntegrityIssueAction.Delete); + + _logger.LogInformation( + "Applying content integrity cleanup for {DeleteIssueCount} delete issue(s).", + deleteIssueCount); + + foreach (ContentIntegrityIssue issue in report.Issues.Where(issue => + issue.Action == IntegrityIssueAction.Delete)) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!targetIndex.TryGetValue(issue.TargetId, out ContentIntegrityTarget? target)) + { + throw new InvalidDataException("The cleanup report references an unknown integrity target."); + } + + string path = ResolveRelativePath(target.RootDirectory, issue.RelativePath); + DeleteEntry(path, issue, target); + } + + foreach (ContentIntegrityTarget target in targets.Where(target => + report.Issues.Any(issue => + issue.TargetId == target.Id && + issue.Action == IntegrityIssueAction.Delete))) + { + DeleteUnexpectedEmptyDirectories(target, cancellationToken); + } + + _logger.LogInformation( + "Completed content integrity cleanup for {DeleteIssueCount} delete issue(s).", + deleteIssueCount); + return Task.CompletedTask; + } + + /// + /// Adds snapshot-comparison issues for one completed filesystem scan. + /// + /// The verified target. + /// The trusted snapshot. + /// The current filesystem scan. + /// The complete issue collection to update. + private static void AddScanIssues( + ContentIntegrityTarget target, + ContentIntegritySnapshotDocument snapshot, + ContentIntegrityScanResult scan, + List issues) + { + var expectedFiles = + snapshot.Files.ToDictionary(file => file.RelativePath, StringComparer.OrdinalIgnoreCase); + + foreach (ContentIntegritySnapshotFileEntry expected in expectedFiles.Values) + { + if (!scan.Files.TryGetValue(expected.RelativePath, out ContentIntegrityScannedFile? current)) + { + issues.Add(CreateIssue( + target, + IntegrityIssueKind.MissingFile, + GetManagedOrManualAction(target.SourceKind, IntegrityIssueKind.MissingFile), + expected.RelativePath, + expectedSizeBytes: expected.Size)); + continue; + } + + if (current.Size != expected.Size || + !string.Equals(current.Sha256, expected.Sha256, StringComparison.OrdinalIgnoreCase)) + { + issues.Add(CreateIssue( + target, + IntegrityIssueKind.ModifiedFile, + GetManagedOrManualAction(target.SourceKind, IntegrityIssueKind.ModifiedFile), + expected.RelativePath, + expectedSizeBytes: expected.Size)); + } + } + + foreach (ContentIntegrityScannedFile current in scan.Files.Values) + { + if (!expectedFiles.ContainsKey(current.RelativePath)) + { + issues.Add(CreateIssue( + target, + IntegrityIssueKind.UnexpectedFile, + GetManagedOrManualAction(target.SourceKind, IntegrityIssueKind.UnexpectedFile), + current.RelativePath)); + } + } + + HashSet expectedEmptyDirectories = + new(snapshot.EmptyDirectories, StringComparer.OrdinalIgnoreCase); + foreach (string directory in scan.EmptyDirectories) + { + if (!expectedEmptyDirectories.Contains(directory)) + { + issues.Add(CreateIssue( + target, + IntegrityIssueKind.EmptyDirectory, + GetManagedOrManualAction(target.SourceKind, IntegrityIssueKind.EmptyDirectory), + directory)); + } + } + + AddScanSafetyIssues(target, scan, issues); + } + + /// + /// Adds unsafe-link and unreadable-entry issues for one completed filesystem scan. + /// + /// The verified target. + /// The current filesystem scan. + /// The complete issue collection to update. + private static void AddScanSafetyIssues( + ContentIntegrityTarget target, + ContentIntegrityScanResult scan, + List issues) + { + foreach (string unsafeLink in scan.UnsafeLinks) + { + issues.Add(CreateIssue( + target, + IntegrityIssueKind.UnsafeLink, + target.SourceKind is ContentSourceKind.ManagedS3 or ContentSourceKind.ManagedSingleFile + ? IntegrityIssueAction.Delete + : IntegrityIssueAction.Block, + unsafeLink)); + } + + foreach (ContentIntegrityScanError error in scan.Errors) + { + issues.Add(CreateIssue( + target, + IntegrityIssueKind.VerificationError, + IntegrityIssueAction.Block, + error.RelativePath, + error.Message)); + } + } + + /// + /// Creates a complete issue for one target. + /// + /// The affected target. + /// The detected issue kind. + /// The offered resolution. + /// The affected relative path. + /// The optional diagnostic message. + /// The expected file size when known. + /// The created issue. + private static ContentIntegrityIssue CreateIssue( + ContentIntegrityTarget target, + IntegrityIssueKind kind, + IntegrityIssueAction action, + string relativePath, + string? message = null, + long? expectedSizeBytes = null) + { + return new ContentIntegrityIssue( + target.Id, + target.DisplayName, + target.SourceKind, + kind, + action, + FileSystemPathSafety.NormalizeRelativePath(relativePath), + message, + expectedSizeBytes); + } + + /// + /// Determines the action for an untracked target. + /// + /// The target source classification. + /// The action used to establish a trusted baseline. + private static IntegrityIssueAction GetUntrackedAction(ContentSourceKind sourceKind) + { + return sourceKind switch + { + ContentSourceKind.ManagedS3 => IntegrityIssueAction.Repair, + ContentSourceKind.ManagedSingleFile => IntegrityIssueAction.Redownload, + ContentSourceKind.Manual => IntegrityIssueAction.Absorb, + _ => IntegrityIssueAction.TrustAsManual, + }; + } + + /// + /// Determines the action for a detected managed or manual content difference. + /// + /// The target source classification. + /// The detected issue kind. + /// The applicable resolution. + private static IntegrityIssueAction GetManagedOrManualAction( + ContentSourceKind sourceKind, + IntegrityIssueKind issueKind) + { + if (sourceKind == ContentSourceKind.Manual) + { + return IntegrityIssueAction.Absorb; + } + + if (sourceKind == ContentSourceKind.ManagedSingleFile) + { + return issueKind is IntegrityIssueKind.UnexpectedFile or IntegrityIssueKind.EmptyDirectory + ? IntegrityIssueAction.Delete + : IntegrityIssueAction.Redownload; + } + + if (sourceKind == ContentSourceKind.ManagedS3) + { + return issueKind is IntegrityIssueKind.UnexpectedFile or IntegrityIssueKind.EmptyDirectory + ? IntegrityIssueAction.Delete + : IntegrityIssueAction.Repair; + } + + return IntegrityIssueAction.TrustAsManual; + } + + /// + /// Deletes one confirmed managed-content entry without following links. + /// + /// The fully qualified entry path. + /// The issue authorizing deletion. + /// The owning integrity target. + private void DeleteEntry( + string path, + ContentIntegrityIssue issue, + ContentIntegrityTarget target) + { + FileAttributes attributes; + try + { + attributes = File.GetAttributes(path); + } + catch (FileNotFoundException) + { + return; + } + catch (DirectoryNotFoundException) + { + return; + } + + if ((attributes & FileAttributes.Directory) != 0) + { + Directory.Delete(path, recursive: false); + } + else + { + File.Delete(path); + } + + _logger.LogInformation( + "Deleted confirmed unexpected integrity entry {RelativePath} from {TargetName}.", + issue.RelativePath, + target.DisplayName); + } + + /// + /// Removes empty directories left after confirmed managed-file cleanup. + /// + /// The managed target to clean. + /// A token that cancels cleanup between directories. + private void DeleteUnexpectedEmptyDirectories( + ContentIntegrityTarget target, + CancellationToken cancellationToken) + { + if (!Directory.Exists(target.RootDirectory)) + { + return; + } + + if (FileSystemPathSafety.IsReparsePoint(target.RootDirectory)) + { + return; + } + + foreach (string directory in Directory + .EnumerateDirectories( + target.RootDirectory, + "*", + FileSystemPathSafety.CreateRecursiveNoLinksOptions()) + .OrderByDescending(path => path.Length) + .ToList()) + { + cancellationToken.ThrowIfCancellationRequested(); + string relativePath = GetRelativePath(target.RootDirectory, directory); + if (IsIgnored(target, relativePath) || + FileSystemPathSafety.IsReparsePoint(directory) || + Directory.EnumerateFileSystemEntries(directory).Any()) + { + continue; + } + + Directory.Delete(directory); + _logger.LogInformation( + "Deleted empty integrity cleanup directory {RelativePath} from {TargetName}.", + relativePath, + target.DisplayName); + } + } + + /// + /// Determines whether a relative path is owned by inactive content and should be preserved without verification. + /// + /// The active integrity target. + /// The normalized relative path. + /// when the path should be ignored. + private static bool IsIgnored(ContentIntegrityTarget target, string relativePath) + { + return target.IgnoredRelativePaths.Contains(FileSystemPathSafety.NormalizeRelativePath(relativePath)); + } + + /// + /// Gets a normalized slash-separated relative path. + /// + /// The containing root directory. + /// The child path. + /// The normalized relative path. + private static string GetRelativePath(string root, string path) + { + string relativePath = Path.GetRelativePath(Path.GetFullPath(root), Path.GetFullPath(path)); + if (relativePath == ".." || + relativePath.StartsWith("../", StringComparison.Ordinal) || + relativePath.StartsWith("..\\", StringComparison.Ordinal) || + Path.IsPathRooted(relativePath)) + { + throw new InvalidDataException("A scanned entry resolved outside the verified target."); + } + + return FileSystemPathSafety.NormalizeRelativePath(relativePath); + } + + /// + /// Resolves a normalized relative path below a target root. + /// + /// The target root. + /// The relative path to resolve. + /// The safe fully qualified path. + private static string ResolveRelativePath(string root, string relativePath) + { + string normalizedRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(root)); + string candidate = Path.GetFullPath(Path.Combine( + normalizedRoot, + relativePath.Replace('/', Path.DirectorySeparatorChar))); + if (!FileSystemPathSafety.IsPathInDirectory(candidate, normalizedRoot)) + { + throw new InvalidDataException("An integrity issue path resolved outside its target root."); + } + + return candidate; + } + +} diff --git a/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityScanner.cs b/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityScanner.cs new file mode 100644 index 00000000..c7eb6e41 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityScanner.cs @@ -0,0 +1,216 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Infrastructure.Common; + +namespace GenLauncherGO.Infrastructure.Integrity.Support; + +/// +/// Scans content integrity targets without following reparse points. +/// +internal static class ContentIntegrityScanner +{ + /// + /// Scans one target without following reparse points. + /// + /// The target to scan. + /// A token that cancels enumeration or hashing. + /// The current safe filesystem state and detected unsafe entries. + public static async Task ScanAsync( + ContentIntegrityTarget target, + CancellationToken cancellationToken) + { + Dictionary files = new(StringComparer.OrdinalIgnoreCase); + List emptyDirectories = new(); + List unsafeLinks = new(); + List errors = new(); + + string root = Path.GetFullPath(target.RootDirectory); + if (!Directory.Exists(root)) + { + return new ContentIntegrityScanResult(files, emptyDirectories, unsafeLinks, errors); + } + + if (FileSystemPathSafety.IsReparsePoint(root)) + { + unsafeLinks.Add("."); + return new ContentIntegrityScanResult(files, emptyDirectories, unsafeLinks, errors); + } + + Stack pendingDirectories = new(); + pendingDirectories.Push(root); + + while (pendingDirectories.Count > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + string directory = pendingDirectories.Pop(); + string directoryRelativePath = GetRelativePath(root, directory); + + try + { + if (!string.Equals(directory, root, StringComparison.OrdinalIgnoreCase) && + FileSystemPathSafety.IsReparsePoint(directory)) + { + unsafeLinks.Add(directoryRelativePath); + continue; + } + + var entries = Directory.EnumerateFileSystemEntries(directory).ToList(); + if (entries.Count == 0 && + !string.Equals(directory, root, StringComparison.OrdinalIgnoreCase) && + !IsIgnored(target, directoryRelativePath)) + { + emptyDirectories.Add(directoryRelativePath); + } + + foreach (string entry in entries) + { + cancellationToken.ThrowIfCancellationRequested(); + string relativePath = GetRelativePath(root, entry); + FileAttributes attributes = File.GetAttributes(entry); + if (IsIgnored(target, relativePath)) + { + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + unsafeLinks.Add(relativePath); + } + + continue; + } + + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + unsafeLinks.Add(relativePath); + continue; + } + + if ((attributes & FileAttributes.Directory) != 0) + { + pendingDirectories.Push(entry); + continue; + } + + FileInfo fileInfo = new(entry); + string sha256 = await ComputeSha256Async(fileInfo.FullName, cancellationToken) + .ConfigureAwait(false); + files[relativePath] = new ContentIntegrityScannedFile(relativePath, fileInfo.Length, sha256); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + errors.Add(new ContentIntegrityScanError(directoryRelativePath, exception.Message)); + } + } + + return new ContentIntegrityScanResult(files, emptyDirectories, unsafeLinks, errors); + } + + /// + /// Determines whether a completed scan exactly matches an expected safe file set. + /// + /// The scan to inspect. + /// The expected file paths relative to the target root. + /// when the scan has exactly the expected safe file set. + public static bool MatchesExpectedFileSet( + ContentIntegrityScanResult scan, + IReadOnlySet expectedRelativePaths) + { + if (scan.EmptyDirectories.Count > 0 || + scan.UnsafeLinks.Count > 0 || + scan.Errors.Count > 0) + { + return false; + } + + var normalizedExpectedPaths = expectedRelativePaths + .Select(FileSystemPathSafety.NormalizeRelativePath) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + return scan.Files.Keys.ToHashSet(StringComparer.OrdinalIgnoreCase) + .SetEquals(normalizedExpectedPaths); + } + + /// + /// Computes the uppercase SHA-256 hash of one file. + /// + /// The file to hash. + /// A token that cancels hashing. + /// The uppercase hexadecimal SHA-256 hash. + private static async Task ComputeSha256Async( + string filePath, + CancellationToken cancellationToken) + { + await using FileStream stream = new( + filePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 1024 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + byte[] hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false); + return Convert.ToHexString(hash); + } + + /// + /// Determines whether a relative path is owned by inactive content and should be preserved without verification. + /// + /// The active integrity target. + /// The normalized relative path. + /// when the path should be ignored. + private static bool IsIgnored(ContentIntegrityTarget target, string relativePath) + { + return target.IgnoredRelativePaths.Contains(FileSystemPathSafety.NormalizeRelativePath(relativePath)); + } + + /// + /// Gets a normalized slash-separated relative path. + /// + /// The containing root directory. + /// The child path. + /// The normalized relative path. + private static string GetRelativePath(string root, string path) + { + string relativePath = Path.GetRelativePath(Path.GetFullPath(root), Path.GetFullPath(path)); + if (relativePath == ".." || + relativePath.StartsWith("../", StringComparison.Ordinal) || + relativePath.StartsWith("..\\", StringComparison.Ordinal) || + Path.IsPathRooted(relativePath)) + { + throw new InvalidDataException("A scanned entry resolved outside the verified target."); + } + + return FileSystemPathSafety.NormalizeRelativePath(relativePath); + } +} + +/// +/// Describes the current safe state of a scanned file. +/// +/// The normalized relative path. +/// The file size in bytes. +/// The uppercase SHA-256 hash. +internal sealed record ContentIntegrityScannedFile(string RelativePath, long Size, string Sha256); + +/// +/// Describes a scan error for a relative path. +/// +/// The relative path that could not be scanned. +/// The diagnostic message. +internal sealed record ContentIntegrityScanError(string RelativePath, string Message); + +/// +/// Describes a completed target scan. +/// +/// The scanned files keyed by normalized relative path. +/// The empty directories discovered during the scan. +/// The reparse points discovered during the scan. +/// The unreadable entries discovered during the scan. +internal sealed record ContentIntegrityScanResult( + IReadOnlyDictionary Files, + IReadOnlyList EmptyDirectories, + IReadOnlyList UnsafeLinks, + IReadOnlyList Errors); diff --git a/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegritySnapshotStore.cs b/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegritySnapshotStore.cs new file mode 100644 index 00000000..3612fd50 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegritySnapshotStore.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Integrity.Support; + +/// +/// Persists content integrity snapshots for verified targets. +/// +internal sealed class ContentIntegritySnapshotStore +{ + /// + /// The current snapshot schema version. + /// + private const int SnapshotSchemaVersion = 1; + + /// + /// The JSON serialization options used for snapshot files. + /// + private static readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true, + }; + + /// + /// The logger used for snapshot persistence diagnostics. + /// + private readonly ILogger _logger; + + /// + /// The directory where integrity snapshots are persisted. + /// + private readonly string _snapshotDirectory; + + /// + /// Initializes a new instance of the class. + /// + /// The directory where integrity snapshots are persisted. + /// The logger used for snapshot persistence diagnostics. + public ContentIntegritySnapshotStore(string snapshotDirectory, ILogger logger) + { + ArgumentException.ThrowIfNullOrWhiteSpace(snapshotDirectory); + _snapshotDirectory = Path.GetFullPath(snapshotDirectory); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Writes a trusted snapshot for a previously completed safe scan. + /// + /// The target being snapshotted. + /// The completed safe scan. + /// A token that cancels persistence. + /// A task that completes when the snapshot is persisted. + public async Task WriteSnapshotAsync( + ContentIntegrityTarget target, + ContentIntegrityScanResult scan, + CancellationToken cancellationToken) + { + ContentIntegritySnapshotDocument snapshot = new( + SnapshotSchemaVersion, + target.Id, + target.SourceKind, + scan.Files.Values + .OrderBy(file => file.RelativePath, StringComparer.OrdinalIgnoreCase) + .Select(file => new ContentIntegritySnapshotFileEntry(file.RelativePath, file.Size, file.Sha256)) + .ToList(), + target.SourceKind == ContentSourceKind.Manual + ? scan.EmptyDirectories + .OrderBy(path => path, StringComparer.OrdinalIgnoreCase) + .ToList() + : Array.Empty()); + + Directory.CreateDirectory(_snapshotDirectory); + string snapshotPath = GetSnapshotPath(target.Id); + string temporaryPath = snapshotPath + ".tmp-" + Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + try + { + await using (FileStream stream = new( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan)) + { + await JsonSerializer.SerializeAsync(stream, snapshot, _jsonOptions, cancellationToken) + .ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + File.Move(temporaryPath, snapshotPath, overwrite: true); + _logger.LogInformation( + "Captured SHA-256 integrity snapshot for {TargetName}; files: {FileCount}.", + target.DisplayName, + snapshot.Files.Count); + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + + /// + /// Reads a persisted snapshot when it exists. + /// + /// The stable target identifier. + /// A token that cancels reading. + /// The snapshot, or when no snapshot exists. + public async Task ReadSnapshotAsync( + string targetId, + CancellationToken cancellationToken) + { + string path = GetSnapshotPath(targetId); + if (!File.Exists(path)) + { + _logger.LogDebug( + "No integrity snapshot exists for target {TargetId}.", + targetId); + return null; + } + + await using FileStream stream = new( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 64 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + ContentIntegritySnapshotDocument? snapshot = + await JsonSerializer.DeserializeAsync( + stream, + _jsonOptions, + cancellationToken).ConfigureAwait(false); + if (snapshot is null || + snapshot.SchemaVersion != SnapshotSchemaVersion || + !string.Equals(snapshot.TargetId, targetId, StringComparison.Ordinal)) + { + _logger.LogWarning( + "Integrity snapshot for target {TargetId} has unsupported schema or ownership.", + targetId); + throw new InvalidDataException("The integrity snapshot schema or ownership is not supported."); + } + + _logger.LogDebug( + "Loaded integrity snapshot for target {TargetId}; files: {FileCount}.", + targetId, + snapshot.Files.Count); + return snapshot; + } + + /// + /// Creates the persisted snapshot path for a stable target identifier. + /// + /// The stable target identifier. + /// The snapshot file path. + private string GetSnapshotPath(string targetId) + { + byte[] identifierHash = SHA256.HashData(Encoding.UTF8.GetBytes(targetId)); + return Path.Combine(_snapshotDirectory, Convert.ToHexString(identifierHash) + ".json"); + } +} + +/// +/// Describes one trusted file entry in a snapshot document. +/// +/// The normalized relative path. +/// The file size in bytes. +/// The uppercase SHA-256 hash. +internal sealed record ContentIntegritySnapshotFileEntry(string RelativePath, long Size, string Sha256); + +/// +/// Describes a persisted trusted content snapshot. +/// +/// The schema version. +/// The target identifier. +/// The target source kind. +/// The trusted file entries. +/// The trusted empty directories for manual content. +internal sealed record ContentIntegritySnapshotDocument( + int SchemaVersion, + string TargetId, + ContentSourceKind SourceKind, + IReadOnlyList Files, + IReadOnlyList EmptyDirectories); diff --git a/GenLauncherGO.Infrastructure/Launching/Composition/DeploymentServiceCollectionExtensions.cs b/GenLauncherGO.Infrastructure/Launching/Composition/DeploymentServiceCollectionExtensions.cs new file mode 100644 index 00000000..4ce5cf46 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Composition/DeploymentServiceCollectionExtensions.cs @@ -0,0 +1,35 @@ +using System; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Infrastructure.Launching.Services; +using GenLauncherGO.Infrastructure.Launching.Support; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Infrastructure.Launching.Composition; + +/// +/// Registers deployment infrastructure services. +/// +public static class DeploymentServiceCollectionExtensions +{ + /// + /// Adds game-directory deployment services to the service collection. + /// + /// The service collection to update. + /// The same service collection. + public static IServiceCollection AddGenLauncherGoDeployment(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services + .AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/DeploymentLaunchPreparationService.cs b/GenLauncherGO.Infrastructure/Launching/Services/DeploymentLaunchPreparationService.cs new file mode 100644 index 00000000..ddaf4fa1 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/DeploymentLaunchPreparationService.cs @@ -0,0 +1,217 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Launching.Services; + +/// +/// Orchestrates launch preparation by translating selected content into deployment packages. +/// +public sealed class DeploymentLaunchPreparationService : ILaunchPreparationService +{ + /// + /// The base game script files that must be hidden while a modded game launch is deployed. + /// + private static readonly IReadOnlyList _baseGameScriptRelativePaths = + [ + "Data/Scripts/MultiplayerScripts.scb", + "Data/Scripts/SkirmishScripts.scb", + "Data/Scripts/Scripts.ini", + ]; + + /// + /// The deployment service that owns all game-folder mutation, cleanup, and recovery side effects. + /// + private readonly IDeploymentService _deploymentService; + + /// + /// The logger used for launch preparation diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The deployment service that owns all game-folder mutation. + /// The logger used for launch preparation diagnostics. + public DeploymentLaunchPreparationService( + IDeploymentService deploymentService, + ILogger logger) + { + _deploymentService = deploymentService ?? throw new ArgumentNullException(nameof(deploymentService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task PrepareAsync( + LaunchPreparationRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + DeploymentRequest deploymentRequest = CreateDeploymentRequest(request); + DeploymentResult result = await _deploymentService.PrepareAsync(deploymentRequest, cancellationToken); + LogDeploymentFailures("prepare launch content", result); + return LaunchPreparationResult.FromDeploymentResult(result); + } + + /// + public async Task CleanupAsync( + LauncherPaths paths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(paths); + + DeploymentResult result = await _deploymentService.CleanupAsync( + new DeploymentCleanupRequest(paths), + cancellationToken); + LogDeploymentFailures("clean up launch content", result); + return LaunchPreparationResult.FromDeploymentResult(result); + } + + /// + public async Task RecoverAsync( + LauncherPaths paths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(paths); + + DeploymentResult result = await _deploymentService.RecoverAsync( + new DeploymentRecoveryRequest(paths), + cancellationToken); + LogDeploymentFailures("recover launch content", result); + return LaunchPreparationResult.FromDeploymentResult(result); + } + + /// + /// Creates a deployment request from a launch preparation request. + /// + /// The launch preparation request. + /// The deployment request. + private static DeploymentRequest CreateDeploymentRequest(LaunchPreparationRequest request) + { + var packages = request.Versions + .Select((version, index) => CreateDeploymentPackage(request, version, index)) + .ToList(); + + IReadOnlyList disabledTargetRelativePaths = request.DisableBaseGameScriptFiles + ? _baseGameScriptRelativePaths + : Array.Empty(); + + return new DeploymentRequest(request.Paths, packages, disabledTargetRelativePaths); + } + + /// + /// Creates a deployment package for one selected content version. + /// + /// The launch preparation request. + /// The selected content version. + /// The selection index used as package precedence. + /// The deployment package. + private static DeploymentPackage CreateDeploymentPackage( + LaunchPreparationRequest request, + ModificationVersion version, + int index) + { + ArgumentNullException.ThrowIfNull(version); + + return new DeploymentPackage( + CreatePackageId(version), + version.Name ?? string.Empty, + ToDeploymentPackageKind(version.ModificationType), + ResolvePackageRoot(request.Paths, version, request.AddonsFolderName, request.PatchesFolderName), + index); + } + + /// + /// Creates a stable package identifier for deployment diagnostics. + /// + /// The selected content version. + /// The package identifier. + private static string CreatePackageId(ModificationVersion version) + { + return $"{version.ModificationType}:{version.Name}:{version.Version}"; + } + + /// + /// Converts a launcher content type into a deployment package kind. + /// + /// The launcher content type. + /// The deployment package kind. + private static DeploymentPackageKind ToDeploymentPackageKind(ModificationType modificationType) + { + return modificationType switch + { + ModificationType.Addon => DeploymentPackageKind.Addon, + ModificationType.Patch => DeploymentPackageKind.Patch, + _ => DeploymentPackageKind.Mod, + }; + } + + /// + /// Resolves the installed package root for a selected content version. + /// + /// The resolved game and launcher paths. + /// The selected content version. + /// The folder name that contains add-on packages below a modification. + /// The folder name that contains patch packages below a modification. + /// The installed package root directory. + private static string ResolvePackageRoot( + LauncherPaths paths, + ModificationVersion version, + string addonsFolderName, + string patchesFolderName) + { + return version.ModificationType switch + { + ModificationType.Addon => Path.Combine( + paths.ModsDirectory, + version.DependenceName ?? string.Empty, + addonsFolderName, + version.Name ?? string.Empty, + version.Version ?? string.Empty), + ModificationType.Patch => Path.Combine( + paths.ModsDirectory, + version.DependenceName ?? string.Empty, + patchesFolderName, + version.Name ?? string.Empty, + version.Version ?? string.Empty), + ModificationType.Mod => Path.Combine( + paths.ModsDirectory, + version.Name ?? string.Empty, + version.Version ?? string.Empty), + _ => string.Empty, + }; + } + + /// + /// Logs deployment failures returned from the wrapped deployment service. + /// + /// The operation name for diagnostics. + /// The deployment result. + private void LogDeploymentFailures(string operationName, DeploymentResult result) + { + if (result.Succeeded) + { + return; + } + + foreach (DeploymentFailure failure in result.Failures) + { + _logger.LogError( + "Failed to {OperationName}. Kind: {FailureKind}; Path: {Path}; Message: {Message}", + operationName, + failure.Kind, + failure.Path, + failure.Message); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/FileSystemDeploymentService.cs b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemDeploymentService.cs new file mode 100644 index 00000000..ff1639f6 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemDeploymentService.cs @@ -0,0 +1,633 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Launching.Support; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Launching.Services; + +/// +/// Deploys selected package files into the game directory and persists enough manifest state to undo the deployment. +/// +public sealed class FileSystemDeploymentService : IDeploymentService +{ + /// + /// The synthetic package id used for files that are only disabled, not replaced by selected content. + /// + private const string DisabledGameFilePackageId = "DisabledGameFile"; + + /// + /// The synthetic source path used for files that are only disabled, not replaced by selected content. + /// + private const string DisabledGameFileSourcePath = "(disabled)"; + + /// + /// The hard-link creator used before copy fallback. + /// + private readonly IHardLinkCreator _hardLinkCreator; + + /// + /// The logger used for deployment diagnostics. + /// + private readonly ILogger _logger; + + /// + /// The deployment state store used for manifests, journals, recovery, and operation locks. + /// + private readonly DeploymentStateStore _stateStore; + + /// + /// Initializes a new instance of the class. + /// + /// The hard-link creator used before copy fallback. + /// The logger used for deployment diagnostics. + public FileSystemDeploymentService( + IHardLinkCreator hardLinkCreator, + ILogger logger) + { + _hardLinkCreator = hardLinkCreator ?? throw new ArgumentNullException(nameof(hardLinkCreator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _stateStore = new DeploymentStateStore(_logger); + } + + /// + public Task PrepareAsync( + DeploymentRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + try + { + using FileStream deploymentLock = DeploymentStateStore.AcquireDeploymentLock(request.Paths); + return Task.FromResult(PrepareWithLock(request, cancellationToken)); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Deployment preparation failed before deployment recovery could run."); + return Task.FromResult(DeploymentResult.Failure(new DeploymentFailure( + DeploymentFailureKind.FileSystem, + request.Paths.GameDirectory, + ex.Message))); + } + } + + /// + public Task CleanupAsync( + DeploymentCleanupRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + try + { + using FileStream deploymentLock = DeploymentStateStore.AcquireDeploymentLock(request.Paths); + return Task.FromResult(CleanupCore(request.Paths, cancellationToken)); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Deployment cleanup failed."); + return Task.FromResult(DeploymentResult.Failure(new DeploymentFailure( + DeploymentFailureKind.FileSystem, + request.Paths.GameDirectory, + ex.Message))); + } + } + + /// + public Task RecoverAsync( + DeploymentRecoveryRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + try + { + using FileStream deploymentLock = DeploymentStateStore.AcquireDeploymentLock(request.Paths); + return Task.FromResult(RecoverCore(request.Paths, cancellationToken)); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Deployment recovery failed."); + return Task.FromResult(DeploymentResult.Failure(new DeploymentFailure( + DeploymentFailureKind.Manifest, + request.Paths.GameDirectory, + ex.Message))); + } + } + + /// + /// Prepares a deployment while the deployment operation lock is held. + /// + /// The deployment request. + /// A token that cancels deployment work. + /// The deployment result. + private DeploymentResult PrepareWithLock( + DeploymentRequest request, + CancellationToken cancellationToken) + { + try + { + DeploymentResult cleanupResult = CleanupCore(request.Paths, cancellationToken); + if (!cleanupResult.Succeeded) + { + return cleanupResult; + } + + string deploymentId = Guid.NewGuid().ToString("N"); + DeploymentStatePaths paths = DeploymentStateStore.CreatePaths(request.Paths, deploymentId); + Directory.CreateDirectory(paths.DeploymentDirectory); + Directory.CreateDirectory(paths.BackupDirectory); + if (File.Exists(paths.JournalPath)) + { + File.Delete(paths.JournalPath); + } + + IReadOnlyList files = DeploymentFilePlanner.ResolveDeploymentFiles(request); + var createdDirectories = new HashSet(StringComparer.OrdinalIgnoreCase); + var entries = new List(); + var backedUpTargetPaths = new Dictionary(StringComparer.OrdinalIgnoreCase); + var deployedTargetPaths = files + .Select(file => file.TargetRelativePath) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + BackupDisabledTargets( + request, + paths, + deploymentId, + deployedTargetPaths, + backedUpTargetPaths, + entries, + cancellationToken); + + foreach (ResolvedDeploymentFile file in files) + { + cancellationToken.ThrowIfCancellationRequested(); + + string targetPath = DeploymentPathResolver.ResolveGamePath(request.Paths, file.TargetRelativePath); + EnsureSafeGameMutationPath(request.Paths, targetPath); + string targetDirectory = Path.GetDirectoryName(targetPath) ?? request.Paths.GameDirectory; + foreach (string directory in DeploymentFilePlanner.GetDirectoriesToCreate( + request.Paths.GameDirectory, + targetDirectory)) + { + if (!Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + string relativeDirectory = DeploymentPathResolver.ToRelativeManifestPath( + request.Paths.GameDirectory, + directory); + createdDirectories.Add(relativeDirectory); + DeploymentStateStore.AppendJournal( + paths.JournalPath, + DeploymentJournalRecord.DirectoryCreated(relativeDirectory)); + } + } + + string? backupRelativePath = null; + if (!backedUpTargetPaths.TryGetValue(file.TargetRelativePath, out backupRelativePath) && + File.Exists(targetPath)) + { + backupRelativePath = BackupTargetFile( + paths, + deploymentId, + file.TargetRelativePath, + targetPath); + backedUpTargetPaths[file.TargetRelativePath] = backupRelativePath; + } + + DeploymentStateStore.AppendJournal(paths.JournalPath, DeploymentJournalRecord.FileDeploymentStarted( + file.SourcePath, + file.TargetRelativePath, + backupRelativePath, + file.PackageId)); + + DeploymentMethod method = DeployFile(file.SourcePath, targetPath); + DeploymentStateStore.AppendJournal(paths.JournalPath, DeploymentJournalRecord.FileDeployed( + file.SourcePath, + file.TargetRelativePath, + method, + backupRelativePath, + file.PackageId)); + + entries.Add(new DeploymentFileDocument( + file.SourcePath, + file.TargetRelativePath, + method, + backupRelativePath, + file.PackageId, + new FileInfo(targetPath).Length, + File.GetLastWriteTimeUtc(targetPath))); + } + + DeploymentManifestDocument document = new( + SchemaVersion: 1, + deploymentId, + DateTimeOffset.UtcNow, + entries, + createdDirectories.OrderByDescending(path => path.Length).ToList()); + DeploymentStateStore.WriteManifest(paths.ActiveManifestPath, document); + _logger.LogInformation("Prepared deployment {DeploymentId} with {FileCount} file(s).", deploymentId, + entries.Count); + return DeploymentResult.Success(DeploymentStateStore.ToCoreManifest(document)); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Deployment preparation failed."); + DeploymentFailure prepareFailure = new( + DeploymentFailureKind.FileSystem, + request.Paths.GameDirectory, + ex.Message); + + DeploymentResult recoveryResult; + try + { + recoveryResult = RecoverCore(request.Paths, CancellationToken.None); + } + catch (Exception recoveryException) + { + _logger.LogError(recoveryException, "Deployment recovery failed after preparation failure."); + recoveryResult = DeploymentResult.Failure(new DeploymentFailure( + DeploymentFailureKind.Manifest, + request.Paths.GameDirectory, + recoveryException.Message)); + } + + if (recoveryResult.Succeeded) + { + return DeploymentResult.Failure(prepareFailure); + } + + return new DeploymentResult( + false, + new[] { prepareFailure }.Concat(recoveryResult.Failures).ToArray(), + recoveryResult.Manifest); + } + } + + /// + /// Cleans a deployment while the deployment operation lock is held. + /// + /// The launcher paths. + /// A token that cancels cleanup work. + /// The cleanup result. + private DeploymentResult CleanupCore(LauncherPaths paths, CancellationToken cancellationToken) + { + DeploymentStatePaths deploymentPaths = DeploymentStateStore.CreatePaths(paths, deploymentId: string.Empty); + DeploymentManifestDocument? manifest = _stateStore.ReadManifestOrJournal(deploymentPaths); + + if (manifest is null) + { + DeploymentStateStore.DeleteDeploymentStateFiles(deploymentPaths); + return DeploymentResult.Success(); + } + + CleanupManifest(paths, deploymentPaths, manifest, cancellationToken); + DeleteEmptyBackupDirectories(deploymentPaths, cancellationToken); + DeploymentStateStore.DeleteDeploymentStateFiles(deploymentPaths); + + _logger.LogInformation("Cleaned deployment {DeploymentId}.", manifest.DeploymentId); + return DeploymentResult.Success(DeploymentStateStore.ToCoreManifest(manifest)); + } + + /// + /// Recovers deployment state while the deployment operation lock is held. + /// + /// The launcher paths. + /// A token that cancels recovery work. + /// The recovery result. + private DeploymentResult RecoverCore(LauncherPaths paths, CancellationToken cancellationToken) + { + DeploymentStatePaths deploymentPaths = DeploymentStateStore.CreatePaths(paths, deploymentId: string.Empty); + DeploymentManifestDocument? manifest = _stateStore.ReadManifestOrJournal(deploymentPaths); + + if (manifest is null) + { + DeploymentStateStore.DeleteDeploymentStateFiles(deploymentPaths); + return DeploymentResult.Success(); + } + + CleanupManifest(paths, deploymentPaths, manifest, cancellationToken); + DeleteEmptyBackupDirectories(deploymentPaths, cancellationToken); + DeploymentStateStore.DeleteDeploymentStateFiles(deploymentPaths); + + _logger.LogInformation("Recovered deployment state for {DeploymentId}.", manifest.DeploymentId); + return DeploymentResult.Success(DeploymentStateStore.ToCoreManifest(manifest)); + } + + /// + /// Deploys a file with a hard link first and copy fallback. + /// + /// The source file path. + /// The target file path. + /// The deployment method used. + private DeploymentMethod DeployFile(string sourcePath, string targetPath) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + sourcePath, + "Deployment source paths must be rooted.", + "Deployment source paths must not contain reparse points."); + + if (_hardLinkCreator.TryCreateHardLink(targetPath, sourcePath)) + { + return DeploymentMethod.HardLink; + } + + File.Copy(sourcePath, targetPath, overwrite: true); + _logger.LogInformation( + "Hard-link deployment failed for {FileName}; copied the file instead.", + Path.GetFileName(targetPath)); + return DeploymentMethod.Copy; + } + + /// + /// Backs up deployment targets that should be hidden without deploying a replacement file. + /// + /// The deployment request. + /// The deployment state paths. + /// The active deployment id. + /// The target paths that selected packages will deploy. + /// The backup lookup to update for normal deployment reuse. + /// The manifest entries to update for disabled-only files. + /// A token that cancels deployment work. + private void BackupDisabledTargets( + DeploymentRequest request, + DeploymentStatePaths deploymentPaths, + string deploymentId, + IReadOnlySet deployedTargetPaths, + Dictionary backedUpTargetPaths, + List entries, + CancellationToken cancellationToken) + { + foreach (string disabledTargetRelativePath in request.DisabledTargetRelativePaths) + { + cancellationToken.ThrowIfCancellationRequested(); + + string targetRelativePath = DeploymentPathResolver.NormalizeManifestPath(disabledTargetRelativePath); + if (backedUpTargetPaths.ContainsKey(targetRelativePath)) + { + continue; + } + + string targetPath = DeploymentPathResolver.ResolveGamePath(request.Paths, targetRelativePath); + EnsureSafeGameMutationPath(request.Paths, targetPath); + if (!File.Exists(targetPath)) + { + _logger.LogDebug( + "Skipped disabling base game file {FileName} because it does not exist.", + Path.GetFileName(targetPath)); + continue; + } + + string backupRelativePath = BackupTargetFile( + deploymentPaths, + deploymentId, + targetRelativePath, + targetPath); + backedUpTargetPaths[targetRelativePath] = backupRelativePath; + + if (!deployedTargetPaths.Contains(targetRelativePath)) + { + string backupPath = DeploymentPathResolver.ResolveDeploymentStatePath( + deploymentPaths.DeploymentDirectory, + backupRelativePath); + FileInfo backupFile = new(backupPath); + entries.Add(new DeploymentFileDocument( + DisabledGameFileSourcePath, + targetRelativePath, + DeploymentMethod.Copy, + backupRelativePath, + DisabledGameFilePackageId, + backupFile.Length, + backupFile.LastWriteTimeUtc)); + } + + _logger.LogInformation( + "Temporarily disabled base game file {FileName} for modded launch deployment.", + Path.GetFileName(targetPath)); + } + } + + /// + /// Backs up an existing target file into launcher-owned deployment state. + /// + /// The deployment state paths. + /// The active deployment id. + /// The game-directory-relative target path. + /// The full target path. + /// The deployment-state-relative backup path. + private static string BackupTargetFile( + DeploymentStatePaths deploymentPaths, + string deploymentId, + string targetRelativePath, + string targetPath) + { + string backupRelativePath = CreateBackupRelativePath(deploymentId, targetRelativePath); + string backupPath = DeploymentPathResolver.ResolveDeploymentStatePath( + deploymentPaths.DeploymentDirectory, + backupRelativePath); + backupPath = FileSystemPathSafety.ResolveOwnedSubpath( + deploymentPaths.DeploymentDirectory, + backupPath, + "Deployment backup paths must stay inside the deployment directory.", + "Deployment backup paths must not contain reparse points."); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath) ?? deploymentPaths.BackupDirectory); + DeploymentStateStore.AppendJournal( + deploymentPaths.JournalPath, + DeploymentJournalRecord.FileBackupStarted(targetRelativePath, backupRelativePath)); + File.Move(targetPath, backupPath, overwrite: true); + DeploymentStateStore.AppendJournal( + deploymentPaths.JournalPath, + DeploymentJournalRecord.FileBackedUp(targetRelativePath, backupRelativePath)); + return backupRelativePath; + } + + /// + /// Creates a deployment-state-relative backup path for a target file. + /// + /// The active deployment id. + /// The game-directory-relative target path. + /// The deployment-state-relative backup path. + private static string CreateBackupRelativePath(string deploymentId, string targetRelativePath) + { + return Path.Combine( + DeploymentStateStore.BackupsDirectoryName, + deploymentId, + targetRelativePath) + .Replace('\\', '/'); + } + + /// + /// Cleans a manifest from the game directory. + /// + /// The launcher paths. + /// The deployment state paths. + /// The manifest to clean. + /// A token that cancels cleanup work. + private void CleanupManifest( + LauncherPaths paths, + DeploymentStatePaths deploymentPaths, + DeploymentManifestDocument manifest, + CancellationToken cancellationToken) + { + foreach (DeploymentFileDocument file in manifest.Files) + { + cancellationToken.ThrowIfCancellationRequested(); + + string targetPath = DeploymentPathResolver.ResolveGamePath(paths, file.TargetRelativePath); + EnsureSafeGameMutationPath(paths, targetPath); + if (string.IsNullOrWhiteSpace(file.BackupRelativePath)) + { + if (File.Exists(targetPath)) + { + File.Delete(targetPath); + DeploymentStateStore.AppendJournal( + deploymentPaths.JournalPath, + DeploymentJournalRecord.FileCleanupDeleted(file.TargetRelativePath)); + } + + continue; + } + + string backupPath = DeploymentPathResolver.ResolveDeploymentStatePath( + deploymentPaths.DeploymentDirectory, + file.BackupRelativePath); + backupPath = FileSystemPathSafety.ResolveOwnedSubpath( + deploymentPaths.DeploymentDirectory, + backupPath, + "Deployment backup paths must stay inside the deployment directory.", + "Deployment backup paths must not contain reparse points."); + if (!File.Exists(backupPath)) + { + _logger.LogInformation( + "Skipped restoring deployment target {FileName} because its backup is missing; it may already be restored.", + Path.GetFileName(targetPath)); + continue; + } + + DeploymentStateStore.AppendJournal(deploymentPaths.JournalPath, DeploymentJournalRecord.FileCleanupRestoreStarted( + file.TargetRelativePath, + file.BackupRelativePath)); + + if (File.Exists(targetPath)) + { + File.Delete(targetPath); + } + + Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? paths.GameDirectory); + File.Move(backupPath, targetPath, overwrite: true); + DeploymentStateStore.AppendJournal(deploymentPaths.JournalPath, DeploymentJournalRecord.FileCleanupRestored( + file.TargetRelativePath, + file.BackupRelativePath)); + } + + foreach (string relativeDirectory in manifest.CreatedDirectories.OrderByDescending(path => path.Length)) + { + cancellationToken.ThrowIfCancellationRequested(); + + string directoryPath = DeploymentPathResolver.ResolveGamePath(paths, relativeDirectory); + EnsureSafeGameMutationPath(paths, directoryPath); + if (!Directory.Exists(directoryPath)) + { + continue; + } + + if (!Directory.EnumerateFileSystemEntries(directoryPath).Any()) + { + Directory.Delete(directoryPath); + continue; + } + + _logger.LogInformation( + "Left deployment-created directory {DirectoryName} because it contains non-deployed files.", + Path.GetFileName(directoryPath)); + } + } + + /// + /// Verifies that a game-directory mutation target stays in the game folder and does not cross child reparse points. + /// + /// The launcher paths. + /// The mutation target path. + private static void EnsureSafeGameMutationPath(LauncherPaths paths, string targetPath) + { + string gameRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(paths.GameDirectory)); + string normalizedTargetPath = Path.GetFullPath(targetPath); + if (!FileSystemPathSafety.IsPathInDirectory(normalizedTargetPath, gameRoot)) + { + throw new InvalidDataException("Deployment target paths must stay inside the game directory."); + } + + string relativePath = Path.GetRelativePath(gameRoot, normalizedTargetPath); + if (string.Equals(relativePath, ".", StringComparison.Ordinal)) + { + return; + } + + string currentPath = gameRoot; + string[] segments = relativePath.Split( + new[] { Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar }, + StringSplitOptions.RemoveEmptyEntries); + foreach (string segment in segments) + { + currentPath = Path.Combine(currentPath, segment); + if (!Directory.Exists(currentPath) && !File.Exists(currentPath)) + { + return; + } + + if (FileSystemPathSafety.IsReparsePoint(currentPath)) + { + throw new InvalidDataException("Deployment target paths must not contain reparse points."); + } + } + } + + /// + /// Deletes empty deployment backup directories left after restored originals have been moved back. + /// + /// The deployment state paths. + /// A token that cancels backup cleanup between directories. + private static void DeleteEmptyBackupDirectories( + DeploymentStatePaths deploymentPaths, + CancellationToken cancellationToken) + { + string backupRoot = Path.Combine( + deploymentPaths.DeploymentDirectory, + DeploymentStateStore.BackupsDirectoryName); + if (!Directory.Exists(backupRoot) || + FileSystemPathSafety.IsReparsePoint(backupRoot)) + { + return; + } + + foreach (string directory in Directory + .EnumerateDirectories( + backupRoot, + "*", + FileSystemPathSafety.CreateRecursiveNoLinksOptions()) + .OrderByDescending(path => path.Length) + .ToList()) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!FileSystemPathSafety.IsReparsePoint(directory) && + !Directory.EnumerateFileSystemEntries(directory).Any()) + { + Directory.Delete(directory); + } + } + + cancellationToken.ThrowIfCancellationRequested(); + if (!Directory.EnumerateFileSystemEntries(backupRoot).Any()) + { + Directory.Delete(backupRoot); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionService.cs b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionService.cs new file mode 100644 index 00000000..b5f145f8 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionService.cs @@ -0,0 +1,691 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Contracts; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Remote; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Launching.Services; + +/// +/// Resolves launch-readiness integrity issues using persisted snapshots, package repair, and cache refresh. +/// +public sealed class FileSystemLaunchContentIntegrityResolutionService : ILaunchContentIntegrityResolutionService +{ + /// + /// The integrity service used to verify, snapshot, and clean target content. + /// + private readonly IContentIntegrityService _integrityService; + + /// + /// The target builder used to construct launch content integrity targets. + /// + private readonly ILaunchContentIntegrityTargetBuilder _targetBuilder; + + /// + /// The S3 manifest reader used for managed S3 repairs. + /// + private readonly IS3ObjectManifestReader _manifestReader; + + /// + /// The S3 package updater used for managed S3 repairs. + /// + private readonly IS3PackageUpdater _s3PackageUpdater; + + /// + /// The single-file package updater used for managed single-file repairs. + /// + private readonly ISingleFilePackageUpdater _singleFilePackageUpdater; + + /// + /// The asset downloader used to refresh launcher-owned image cache files. + /// + private readonly IRemoteAssetDownloader _assetDownloader; + + /// + /// The launcher content catalog commands used to persist content source changes. + /// + private readonly ILauncherContentCatalogCommands _catalogCommands; + + /// + /// The logger used for integrity-resolution diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The integrity service used to verify, snapshot, and clean target content. + /// The target builder used to construct launch content integrity targets. + /// The S3 manifest reader used for managed S3 repairs. + /// The S3 package updater used for managed S3 repairs. + /// The single-file package updater used for managed single-file repairs. + /// The asset downloader used to refresh launcher-owned image cache files. + /// The launcher content catalog commands used to persist content source changes. + /// The logger used for integrity-resolution diagnostics. + public FileSystemLaunchContentIntegrityResolutionService( + IContentIntegrityService integrityService, + ILaunchContentIntegrityTargetBuilder targetBuilder, + IS3ObjectManifestReader manifestReader, + IS3PackageUpdater s3PackageUpdater, + ISingleFilePackageUpdater singleFilePackageUpdater, + IRemoteAssetDownloader assetDownloader, + ILauncherContentCatalogCommands catalogCommands, + ILogger logger) + { + _integrityService = integrityService ?? throw new ArgumentNullException(nameof(integrityService)); + _targetBuilder = targetBuilder ?? throw new ArgumentNullException(nameof(targetBuilder)); + _manifestReader = manifestReader ?? throw new ArgumentNullException(nameof(manifestReader)); + _s3PackageUpdater = s3PackageUpdater ?? throw new ArgumentNullException(nameof(s3PackageUpdater)); + _singleFilePackageUpdater = singleFilePackageUpdater ?? + throw new ArgumentNullException(nameof(singleFilePackageUpdater)); + _assetDownloader = assetDownloader ?? throw new ArgumentNullException(nameof(assetDownloader)); + _catalogCommands = catalogCommands ?? throw new ArgumentNullException(nameof(catalogCommands)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task VerifyAsync( + LaunchContentIntegrityTargetRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + IReadOnlyList contexts = _targetBuilder.BuildTargets(request); + ContentIntegrityReport report = await _integrityService.VerifyAsync( + contexts.Select(context => context.Target).ToList(), + cancellationToken); + return new LaunchContentIntegrityVerificationResult(report, contexts); + } + + /// + public async Task InitializeUntrackedManagedCachesAsync( + LaunchContentIntegrityResolutionRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var untrackedManagedCacheIds = request.Report.Issues + .Where(issue => + issue.Kind == IntegrityIssueKind.Untracked && + issue.SourceKind is ContentSourceKind.ManagedS3 or ContentSourceKind.ManagedSingleFile) + .Select(issue => issue.TargetId) + .Where(targetId => request.Report.Issues + .Where(issue => issue.TargetId == targetId) + .All(issue => issue.Kind == IntegrityIssueKind.Untracked)) + .ToHashSet(StringComparer.Ordinal); + var cacheContexts = request.TargetContexts + .Where(context => + context.IsCache && + untrackedManagedCacheIds.Contains(context.Target.Id)) + .ToList(); + + bool initializedAny = false; + foreach (LaunchContentIntegrityTargetContext context in cacheContexts) + { + if (!await _integrityService.CaptureSnapshotIfMatchesExpectedFileSetAsync( + context.Target, + BuildExpectedRemoteCachePaths(context), + cancellationToken)) + { + continue; + } + + _logger.LogInformation( + "Initialized managed remote image integrity for {ContentName}.", + context.Version.DisplayName); + initializedAny = true; + } + + return initializedAny; + } + + /// + public async Task ResolveAsync( + LaunchContentIntegrityResolutionRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var contextIndex = + request.TargetContexts.ToDictionary(context => context.Target.Id, StringComparer.Ordinal); + + foreach (LaunchContentIntegrityTargetContext context in request.TargetContexts.Where(context => + request.Report.Issues.Any(issue => + issue.TargetId == context.Target.Id && + issue.Action == IntegrityIssueAction.TrustAsManual))) + { + context.Version.ContentSourceKind = ContentSourceKind.Manual; + ContentIntegrityTarget manualTarget = context.Target with + { + SourceKind = ContentSourceKind.Manual, + }; + await _integrityService.CaptureSnapshotAsync(manualTarget, cancellationToken); + } + + _catalogCommands.SaveLauncherData(); + + foreach (LaunchContentIntegrityTargetContext context in request.TargetContexts.Where(context => + request.Report.Issues.Any(issue => + issue.TargetId == context.Target.Id && + issue.Action == IntegrityIssueAction.Absorb))) + { + await _integrityService.CaptureSnapshotAsync(context.Target, cancellationToken); + } + + await _integrityService.ApplyCleanupAsync( + request.Report, + request.TargetContexts.Select(context => context.Target).ToList(), + cancellationToken); + + foreach (LaunchContentIntegrityTargetContext context in request.TargetContexts.Where(context => + request.Report.Issues.Any(issue => + issue.TargetId == context.Target.Id && + issue.Action is IntegrityIssueAction.Repair or IntegrityIssueAction.Redownload))) + { + if (context.IsCache) + { + await RefreshManagedCacheAsync(context, cancellationToken); + progress?.Report(LaunchContentIntegrityResolutionProgress.Complete(context.Target.Id)); + } + else + { + await RepairManagedPackageAsync( + request.Paths, + context, + request.Report, + new TargetPackageProgress(context.Target.Id, progress), + cancellationToken); + } + } + + foreach (string targetId in request.Report.Issues + .Where(issue => + issue.SourceKind is ContentSourceKind.ManagedS3 or ContentSourceKind.ManagedSingleFile) + .Select(issue => issue.TargetId) + .Distinct(StringComparer.Ordinal)) + { + if (contextIndex.TryGetValue(targetId, out LaunchContentIntegrityTargetContext? context)) + { + await _integrityService.CaptureSnapshotAsync(context.Target, cancellationToken); + } + } + } + + /// + public async Task RegisterManualImportAsync( + LaunchContentIntegrityVersionRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + request.Version.ContentSourceKind = ContentSourceKind.Manual; + _catalogCommands.SaveLauncherData(); + + ContentIntegrityTarget packageTarget = CreatePackageTarget(request); + await _integrityService.CaptureSnapshotAsync(packageTarget, cancellationToken); + + if (request.Version.ModificationType == ModificationType.Mod) + { + ContentIntegrityTarget cacheTarget = CreateCacheTarget(request); + await _integrityService.CaptureSnapshotAsync(cacheTarget, cancellationToken); + } + } + + /// + public async Task CaptureManagedInstallSnapshotAsync( + LaunchContentIntegrityVersionRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + if (request.Version.EffectiveContentSourceKind is not + (ContentSourceKind.ManagedS3 or ContentSourceKind.ManagedSingleFile)) + { + return; + } + + ContentIntegrityTarget packageTarget = CreatePackageTarget(request); + await _integrityService.CaptureSnapshotAsync(packageTarget, cancellationToken); + + if (request.Version.ModificationType == ModificationType.Mod) + { + ContentIntegrityTarget cacheTarget = CreateCacheTarget(request); + LaunchContentIntegrityTargetContext cacheContext = new(cacheTarget, request.Version, isCache: true); + if (!await _integrityService.CaptureSnapshotIfMatchesExpectedFileSetAsync( + cacheTarget, + BuildExpectedRemoteCachePaths(cacheContext), + cancellationToken)) + { + await RefreshManagedCacheAsync(cacheContext, cancellationToken); + await _integrityService.CaptureSnapshotAsync(cacheTarget, cancellationToken); + } + } + } + + /// + public async Task CaptureManualImageSnapshotAsync( + LaunchContentIntegrityVersionRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + if (request.Version.EffectiveContentSourceKind != ContentSourceKind.Manual) + { + return; + } + + await _integrityService.CaptureSnapshotAsync( + CreateCacheTarget(request), + cancellationToken); + } + + /// + /// Creates the package target for a single-version operation. + /// + /// The single-version request. + /// The package target. + private ContentIntegrityTarget CreatePackageTarget(LaunchContentIntegrityVersionRequest request) + { + return BuildSingleVersionContexts(request) + .First(context => !context.IsCache) + .Target; + } + + /// + /// Creates the cache target for a single-version operation. + /// + /// The single-version request. + /// The cache target. + private ContentIntegrityTarget CreateCacheTarget(LaunchContentIntegrityVersionRequest request) + { + return BuildSingleVersionContexts(request) + .First(context => context.IsCache) + .Target; + } + + /// + /// Builds target contexts for a single-version operation. + /// + /// The single-version request. + /// The target contexts. + private IReadOnlyList BuildSingleVersionContexts( + LaunchContentIntegrityVersionRequest request) + { + return _targetBuilder.BuildTargets( + new LaunchContentIntegrityTargetRequest( + request.Paths, + new[] { request.Version }, + request.AllVersions, + request.CacheDisplayNameSuffix, + request.AddonsFolderName, + request.PatchesFolderName)); + } + + /// + /// Repairs a managed remote package. + /// + /// The resolved game and launcher paths. + /// The target context to repair. + /// The integrity report that identified the target issues. + /// The package progress reporter. + /// A token that cancels repair work. + private async Task RepairManagedPackageAsync( + GenLauncherGO.Core.Startup.LauncherPaths paths, + LaunchContentIntegrityTargetContext context, + ContentIntegrityReport report, + IProgress progress, + CancellationToken cancellationToken) + { + ContentSourceKind sourceKind = context.Version.EffectiveContentSourceKind; + if (sourceKind == ContentSourceKind.ManagedS3) + { + S3ObjectManifestRequest manifestRequest = S3CatalogDefaults.CreateManifestRequest(context.Version); + IReadOnlyList files = await _manifestReader.ReadManifestAsync( + manifestRequest, + cancellationToken); + var hashCheckedExtensions = files + .Select(file => Path.GetExtension(file.FileName)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + hashCheckedExtensions.Add(".gib"); + + IReadOnlyList repairFiles = SelectS3FileRepairEntries( + report, + context.Target.Id, + files); + if (repairFiles.Count > 0) + { + _logger.LogInformation( + "Repairing {FileCount} S3 package file(s) in place for {ContentName}.", + repairFiles.Count, + context.Version.DisplayName); + await _s3PackageUpdater.RepairFilesAsync( + new S3PackageFileRepairRequest( + repairFiles, + manifestRequest.Endpoint, + manifestRequest.BucketName, + manifestRequest.Prefix, + manifestRequest.AccessKey, + manifestRequest.SecretKey, + context.Target.RootDirectory, + hashCheckedExtensions, + manifestRequest.UseSsl), + progress, + cancellationToken); + return; + } + + _logger.LogInformation( + "Repairing S3 package {ContentName} with full package replacement.", + context.Version.DisplayName); + + await _s3PackageUpdater.UpdateAsync( + new S3PackageUpdateRequest( + files, + manifestRequest.Endpoint, + manifestRequest.BucketName, + manifestRequest.Prefix, + manifestRequest.AccessKey, + manifestRequest.SecretKey, + paths.GetPackageTemporaryFolderPath(context.Target.RootDirectory), + context.Target.RootDirectory, + context.Target.RootDirectory, + hashCheckedExtensions, + manifestRequest.UseSsl), + progress, + cancellationToken); + return; + } + + if (sourceKind == ContentSourceKind.ManagedSingleFile) + { + await _singleFilePackageUpdater.UpdateAsync( + new SingleFilePackageUpdateRequest( + DownloadLinkResolver.ResolveDownloadUri(context.Version.SimpleDownloadLink), + paths.GetPackageTemporaryFolderPath(context.Target.RootDirectory), + context.Target.RootDirectory), + progress, + cancellationToken); + return; + } + + throw new InvalidOperationException("Only managed remote content can be repaired automatically."); + } + + /// + /// Selects the S3 manifest entries that correspond to file-level repair issues for one integrity target. + /// + /// The complete integrity report. + /// The target identifier to inspect. + /// The remote manifest entries. + /// + /// The manifest entries that can be repaired in place, or an empty collection when the issue set requires a full + /// package repair. + /// + private static IReadOnlyList SelectS3FileRepairEntries( + ContentIntegrityReport report, + string targetId, + IReadOnlyList files) + { + var repairIssues = report.Issues + .Where(issue => + string.Equals(issue.TargetId, targetId, StringComparison.Ordinal) && + issue.Action is IntegrityIssueAction.Repair or IntegrityIssueAction.Redownload) + .ToList(); + if (repairIssues.Count == 0 || + repairIssues.Any(issue => + issue.Action != IntegrityIssueAction.Repair || + issue.Kind is not (IntegrityIssueKind.MissingFile or IntegrityIssueKind.ModifiedFile))) + { + return Array.Empty(); + } + + var remainingIssuePaths = repairIssues + .Select(issue => FileSystemPathSafety.NormalizeRelativePath(issue.RelativePath)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + List selectedFiles = new(); + foreach (RemoteFileManifestEntry file in files) + { + string manifestRelativePath = ManifestPathResolver.NormalizeForManifestIndex(file.FileName); + string installedRelativePath = GetInstalledS3RelativePath(file.FileName); + bool matchesIssue = remainingIssuePaths.Remove(manifestRelativePath); + matchesIssue |= remainingIssuePaths.Remove(installedRelativePath); + if (matchesIssue) + { + selectedFiles.Add(file); + } + } + + return remainingIssuePaths.Count == 0 + ? selectedFiles + : Array.Empty(); + } + + /// + /// Converts a manifest file name into the relative path expected in an installed package snapshot. + /// + /// The manifest file name. + /// The installed relative path. + private static string GetInstalledS3RelativePath(string manifestFileName) + { + string normalizedPath = ManifestPathResolver.NormalizeForManifestIndex(manifestFileName); + if (!string.Equals(Path.GetExtension(normalizedPath), ".big", StringComparison.OrdinalIgnoreCase)) + { + return normalizedPath; + } + + return FileSystemPathSafety.NormalizeRelativePath( + Path.ChangeExtension(normalizedPath, ".gib") ?? normalizedPath); + } + + /// + /// Refreshes a managed launcher-owned cache target from remote asset links. + /// + /// The cache target context. + /// A token that cancels refresh work. + private async Task RefreshManagedCacheAsync( + LaunchContentIntegrityTargetContext context, + CancellationToken cancellationToken) + { + ContentIntegrityTarget target = context.Target; + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + target.RootDirectory, + "Content metadata must resolve to a rooted path.", + "Content metadata resolved through a linked launcher-owned directory."); + Directory.CreateDirectory(target.RootDirectory); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + target.RootDirectory, + "Content metadata must resolve to a rooted path.", + "Content metadata resolved through a linked launcher-owned directory."); + + List assets = BuildRemoteCacheAssets(context.Version, target.RootDirectory); + foreach (string filePath in EnumerateFilesWithoutLinks(target.RootDirectory).ToList()) + { + cancellationToken.ThrowIfCancellationRequested(); + string relativePath = FileSystemPathSafety.NormalizeRelativePath(Path.GetRelativePath( + target.RootDirectory, + filePath)); + if (target.IgnoredRelativePaths.Contains(relativePath)) + { + continue; + } + + File.Delete(filePath); + } + + foreach (string directory in EnumerateDirectoriesWithoutLinks(target.RootDirectory) + .OrderByDescending(path => path.Length) + .ToList()) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!Directory.EnumerateFileSystemEntries(directory).Any()) + { + Directory.Delete(directory); + } + } + + foreach (RemoteCacheAsset asset in assets) + { + await _assetDownloader.DownloadIfMissingAsync( + asset.SourceUri, + asset.DestinationPath, + cancellationToken); + } + } + + /// + /// Builds expected remote cache paths for a target context. + /// + /// The cache target context. + /// The expected relative cache paths. + private static HashSet BuildExpectedRemoteCachePaths(LaunchContentIntegrityTargetContext context) + { + return BuildRemoteCacheAssets(context.Version, context.Target.RootDirectory) + .Select(asset => FileSystemPathSafety.NormalizeRelativePath(Path.GetRelativePath( + context.Target.RootDirectory, + asset.DestinationPath))) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + /// + /// Builds remote cache asset descriptors from version metadata. + /// + /// The content version. + /// The launcher-owned cache directory. + /// The remote cache assets. + private static List BuildRemoteCacheAssets( + ModificationVersion version, + string cacheDirectory) + { + var assets = new List(); + AddRemoteCacheAsset(assets, version.UIImageSourceLink, cacheDirectory, version.Version); + AddRemoteCacheAsset( + assets, + version.ColorsInformation?.GenLauncherBackgroundImageLink, + cacheDirectory, + version.Version + "-background"); + return assets; + } + + /// + /// Adds one remote cache asset when the source link is an absolute URI. + /// + /// The asset collection to update. + /// The source URI text. + /// The launcher-owned cache directory. + /// The destination file base name. + private static void AddRemoteCacheAsset( + List assets, + string? link, + string cacheDirectory, + string baseName) + { + if (!Uri.TryCreate(link, UriKind.Absolute, out Uri? sourceUri)) + { + return; + } + + string extension = Path.GetExtension(sourceUri.LocalPath); + if (!string.Equals(extension, ".png", StringComparison.OrdinalIgnoreCase) && + !string.Equals(extension, ".jpg", StringComparison.OrdinalIgnoreCase) && + !string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase)) + { + extension = ".png"; + } + + assets.Add(new RemoteCacheAsset( + sourceUri, + FileSystemPathSafety.ResolveOwnedSubpath( + cacheDirectory, + Path.Combine(cacheDirectory, baseName + extension), + "Content metadata resolved outside a launcher-owned directory.", + "Content metadata resolved through a linked launcher-owned directory."))); + } + + /// + /// Enumerates files without following linked directories. + /// + /// The root directory to enumerate. + /// The file paths. + private static IEnumerable EnumerateFilesWithoutLinks(string rootDirectory) + { + return Directory.EnumerateFiles( + rootDirectory, + "*", + FileSystemPathSafety.CreateRecursiveNoLinksOptions()); + } + + /// + /// Enumerates directories without following linked directories. + /// + /// The root directory to enumerate. + /// The directory paths. + private static IEnumerable EnumerateDirectoriesWithoutLinks(string rootDirectory) + { + return Directory.EnumerateDirectories( + rootDirectory, + "*", + FileSystemPathSafety.CreateRecursiveNoLinksOptions()); + } + + /// + /// Bridges package updater progress to launch integrity progress by target id. + /// + private sealed class TargetPackageProgress : IProgress + { + /// + /// The target id associated with package progress. + /// + private readonly string _targetId; + + /// + /// The outer progress reporter. + /// + private readonly IProgress? _progress; + + /// + /// Initializes a new instance of the class. + /// + /// The target id associated with package progress. + /// The outer progress reporter. + public TargetPackageProgress( + string targetId, + IProgress? progress) + { + ArgumentException.ThrowIfNullOrWhiteSpace(targetId); + + _targetId = targetId; + _progress = progress; + } + + /// + public void Report(PackageUpdateProgress value) + { + _progress?.Report(LaunchContentIntegrityResolutionProgress.Package(_targetId, value)); + } + } + + /// + /// Describes one remote cache asset. + /// + /// The remote source URI. + /// The local destination path. + private sealed record RemoteCacheAsset( + Uri SourceUri, + string DestinationPath); +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilder.cs b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilder.cs new file mode 100644 index 00000000..87ddb16c --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilder.cs @@ -0,0 +1,280 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Launching.Services; + +/// +/// Builds launch-readiness integrity targets from launcher-owned file-system paths. +/// +public sealed class FileSystemLaunchContentIntegrityTargetBuilder : ILaunchContentIntegrityTargetBuilder +{ + /// + /// The empty ignored path set shared by package targets. + /// + private static readonly HashSet _emptyIgnoredPaths = new(StringComparer.OrdinalIgnoreCase); + + /// + /// The logger used for target construction diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger used for target construction diagnostics. + public FileSystemLaunchContentIntegrityTargetBuilder( + ILogger? logger = null) + { + _logger = logger ?? NullLogger.Instance; + } + + /// + public IReadOnlyList BuildTargets( + LaunchContentIntegrityTargetRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + var contexts = new List(); + foreach (ModificationVersion version in request.ActiveVersions) + { + contexts.Add(new LaunchContentIntegrityTargetContext( + CreatePackageTarget(request, version), + version, + isCache: false)); + + if (version.ModificationType == ModificationType.Mod) + { + contexts.Add(new LaunchContentIntegrityTargetContext( + CreateCacheTarget(request, version), + version, + isCache: true)); + } + } + + if (contexts.Count > 0) + { + _logger.LogInformation( + "Built {TargetCount} launch content integrity target(s) for {VersionCount} active version(s).", + contexts.Count, + request.ActiveVersions.Count); + } + else + { + _logger.LogDebug("Skipped launch content integrity target construction because no active versions were selected."); + } + + return contexts; + } + + /// + /// Creates the package directory integrity target for a selected version. + /// + /// The target construction request. + /// The selected content version. + /// The package integrity target. + private static ContentIntegrityTarget CreatePackageTarget( + LaunchContentIntegrityTargetRequest request, + ModificationVersion version) + { + string packageDirectory = FileSystemPathSafety.ResolveOwnedSubpath( + request.Paths.ModsDirectory, + ResolvePackageRoot( + request.Paths, + version, + request.AddonsFolderName, + request.PatchesFolderName), + "Content metadata resolved outside a launcher-owned directory.", + "Content metadata resolved through a linked launcher-owned directory."); + + return new ContentIntegrityTarget( + CreateTargetId("package", version), + version.DisplayName, + packageDirectory, + version.EffectiveContentSourceKind, + _emptyIgnoredPaths); + } + + /// + /// Creates the launcher image cache integrity target for a selected modification version. + /// + /// The target construction request. + /// The selected modification version. + /// The cache integrity target. + private ContentIntegrityTarget CreateCacheTarget( + LaunchContentIntegrityTargetRequest request, + ModificationVersion version) + { + string cacheDirectory = FileSystemPathSafety.ResolveOwnedSubpath( + request.Paths.ImagesDirectory, + request.Paths.GetModificationImagesDirectory(version.Name), + "Content metadata resolved outside a launcher-owned directory.", + "Content metadata resolved through a linked launcher-owned directory."); + HashSet ignoredPaths = BuildInactiveCacheIgnoredPaths(request, version, cacheDirectory); + + return new ContentIntegrityTarget( + CreateTargetId("cache", version), + version.DisplayName + " " + request.CacheDisplayNameSuffix, + cacheDirectory, + version.EffectiveContentSourceKind, + ignoredPaths); + } + + /// + /// Builds ignored cache paths that belong to inactive versions of the same modification. + /// + /// The target construction request. + /// The selected modification version. + /// The cache directory to inspect. + /// The ignored cache paths. + private HashSet BuildInactiveCacheIgnoredPaths( + LaunchContentIntegrityTargetRequest request, + ModificationVersion version, + string cacheDirectory) + { + var ignoredPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + if (!Directory.Exists(cacheDirectory)) + { + _logger.LogDebug( + "Skipped inactive image-cache ignore discovery for {ContentName} {ContentVersion} because the cache directory does not exist.", + version.Name, + version.Version); + return ignoredPaths; + } + + if (FileSystemPathSafety.IsReparsePoint(cacheDirectory)) + { + _logger.LogWarning( + "Skipped inactive image-cache ignore discovery for {ContentName} {ContentVersion} because the cache directory is a reparse point.", + version.Name, + version.Version); + return ignoredPaths; + } + + var inactiveBaseNames = request.AllVersions + .Where(candidate => + !IsExactVersion(candidate, version) && + string.Equals(candidate.Name, version.Name, StringComparison.OrdinalIgnoreCase)) + .SelectMany(candidate => new[] + { + candidate.Version, + candidate.Version + "-background", + }) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + foreach (string filePath in EnumerateFilesWithoutLinks(cacheDirectory)) + { + string relativePath = FileSystemPathSafety.NormalizeRelativePath(Path.GetRelativePath( + cacheDirectory, + filePath)); + if (inactiveBaseNames.Contains(Path.GetFileNameWithoutExtension(filePath))) + { + ignoredPaths.Add(relativePath); + } + } + + if (ignoredPaths.Count > 0) + { + _logger.LogDebug( + "Ignored {IgnoredPathCount} inactive image-cache file(s) while building integrity target for {ContentName} {ContentVersion}.", + ignoredPaths.Count, + version.Name, + version.Version); + } + + return ignoredPaths; + } + + /// + /// Resolves the installed package root for a selected content version. + /// + /// The resolved game and launcher paths. + /// The selected content version. + /// The folder name that contains add-on packages below a modification. + /// The folder name that contains patch packages below a modification. + /// The installed package root directory. + private static string ResolvePackageRoot( + LauncherPaths paths, + ModificationVersion version, + string addonsFolderName, + string patchesFolderName) + { + return version.ModificationType switch + { + ModificationType.Addon => Path.Combine( + paths.ModsDirectory, + version.DependenceName ?? string.Empty, + addonsFolderName, + version.Name ?? string.Empty, + version.Version ?? string.Empty), + ModificationType.Patch => Path.Combine( + paths.ModsDirectory, + version.DependenceName ?? string.Empty, + patchesFolderName, + version.Name ?? string.Empty, + version.Version ?? string.Empty), + ModificationType.Mod => Path.Combine( + paths.ModsDirectory, + version.Name ?? string.Empty, + version.Version ?? string.Empty), + _ => string.Empty, + }; + } + + /// + /// Creates a stable target identifier. + /// + /// The target kind prefix. + /// The content version. + /// The target identifier. + private static string CreateTargetId(string prefix, ModificationVersion version) + { + return string.Join( + ":", + prefix, + version.ModificationType, + version.DependenceName ?? string.Empty, + version.Name ?? string.Empty, + version.Version ?? string.Empty).ToLowerInvariant(); + } + + /// + /// Determines whether two content versions identify the same package. + /// + /// The candidate version. + /// The selected version. + /// when the versions identify the same package; otherwise, . + private static bool IsExactVersion( + ModificationVersion candidate, + ModificationVersion version) + { + return candidate.ModificationType == version.ModificationType && + string.Equals(candidate.DependenceName, version.DependenceName, StringComparison.OrdinalIgnoreCase) && + string.Equals(candidate.Name, version.Name, StringComparison.OrdinalIgnoreCase) && + string.Equals(candidate.Version, version.Version, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Enumerates files without following linked directories. + /// + /// The root directory to enumerate. + /// The file paths. + private static IEnumerable EnumerateFilesWithoutLinks(string rootDirectory) + { + return Directory.EnumerateFiles( + rootDirectory, + "*", + FileSystemPathSafety.CreateRecursiveNoLinksOptions()); + } + +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryService.cs b/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryService.cs new file mode 100644 index 00000000..9c96d0a8 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryService.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Launching.Services; + +/// +/// Discovers Windows game and World Builder executables through file-system probes. +/// +public sealed class WindowsGameExecutableDiscoveryService : IGameExecutableDiscoveryService +{ + /// + /// The Generals Online launcher executable name. + /// + private const string GeneralsOnlineLauncherExecutable = "generalsonlinezh.exe"; + + /// + /// The original game World Builder executable name. + /// + private const string VanillaWorldBuilderExecutable = "WorldBuilder.exe"; + + /// + /// Resolved launcher paths used to inspect the game directory. + /// + private readonly LauncherPaths _launcherPaths; + + /// + /// Resolves community executable names for managed game variants. + /// + private readonly IGameProcessLauncher _gameProcessLauncher; + + /// + /// Logs file-system probe diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The resolved launcher paths used to inspect the game directory. + /// The launcher used to resolve community executable names. + /// The logger used for file-system probe diagnostics. + public WindowsGameExecutableDiscoveryService( + LauncherPaths launcherPaths, + IGameProcessLauncher gameProcessLauncher, + ILogger logger) + { + _launcherPaths = launcherPaths ?? throw new ArgumentNullException(nameof(launcherPaths)); + _gameProcessLauncher = gameProcessLauncher ?? throw new ArgumentNullException(nameof(gameProcessLauncher)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public IReadOnlyList GetAvailableGameClients(SupportedGame managedGame) + { + var executables = new List(); + string communityExecutable = _gameProcessLauncher.GetCommunityGameExecutableName(managedGame); + + if (IsExecutableAvailable(communityExecutable)) + { + executables.Add(new GameClientExecutable(communityExecutable, GameClientExecutableKind.Community)); + } + + if (IsExecutableAvailable(GeneralsOnlineLauncherExecutable)) + { + executables.Add(new GameClientExecutable( + GeneralsOnlineLauncherExecutable, + GameClientExecutableKind.GeneralsOnline)); + } + + return executables; + } + + /// + public IReadOnlyList GetAvailableWorldBuilders(SupportedGame managedGame) + { + var executables = new List(); + + if (IsExecutableAvailable(VanillaWorldBuilderExecutable)) + { + executables.Add(new WorldBuilderExecutable( + VanillaWorldBuilderExecutable, + WorldBuilderExecutableKind.Vanilla)); + } + + string communityExecutable = _gameProcessLauncher.GetCommunityWorldBuilderExecutableName(managedGame); + if (IsExecutableAvailable(communityExecutable)) + { + executables.Add(new WorldBuilderExecutable( + communityExecutable, + WorldBuilderExecutableKind.Community)); + } + + return executables; + } + + /// + public bool IsExecutableAvailable(string? executableName) + { + if (string.IsNullOrWhiteSpace(executableName)) + { + return false; + } + + try + { + return File.Exists(GetExecutableProbePath(executableName)); + } + catch (Exception exception) when (exception is ArgumentException or IOException or NotSupportedException) + { + _logger.LogWarning( + exception, + "Could not inspect executable availability for {ExecutableName}.", + Path.GetFileName(executableName)); + return false; + } + } + + /// + /// Builds the path used for a file-system existence probe. + /// + /// The executable path or name. + /// The rooted path to inspect. + private string GetExecutableProbePath(string executableName) + { + return Path.IsPathRooted(executableName) + ? executableName + : Path.Combine(_launcherPaths.GameDirectory, executableName); + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameProcessLauncher.cs b/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameProcessLauncher.cs new file mode 100644 index 00000000..b8898178 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameProcessLauncher.cs @@ -0,0 +1,270 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Launching.Support; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Launching.Services; + +/// +/// Launches Windows game and World Builder processes for supported Command & Conquer clients. +/// +public sealed class WindowsGameProcessLauncher : IGameProcessLauncher +{ + /// + /// The observed game-process running time required to treat a launch as successful. + /// + private const int SuccessfulLaunchThresholdMilliseconds = 12000; + + /// + /// The SuperHackers Generals executable name. + /// + private const string SuperHackersGeneralsExecutable = "generalsv.exe"; + + /// + /// The SuperHackers Zero Hour executable name. + /// + private const string SuperHackersZeroHourExecutable = "generalszh.exe"; + + /// + /// The SuperHackers Generals World Builder executable name. + /// + private const string SuperHackersGeneralsWorldBuilderExecutable = "worldbuilderv.exe"; + + /// + /// The SuperHackers Zero Hour World Builder executable name. + /// + private const string SuperHackersZeroHourWorldBuilderExecutable = "worldbuilderzh.exe"; + + /// + /// The Generals Online launcher executable name. + /// + private const string GeneralsOnlineLauncherExecutable = "generalsonlinezh.exe"; + + /// + /// The process-family launcher used to start and wait on processes. + /// + private readonly IProcessFamilyLauncher _processFamilyLauncher; + + /// + /// The logger used for process launch diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The process-family launcher used to start and wait on processes. + /// The logger used for process launch diagnostics. + public WindowsGameProcessLauncher( + IProcessFamilyLauncher processFamilyLauncher, + ILogger logger) + { + _processFamilyLauncher = + processFamilyLauncher ?? throw new ArgumentNullException(nameof(processFamilyLauncher)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public string GetCommunityGameExecutableName(SupportedGame managedGame) + { + return managedGame == SupportedGame.ZeroHour + ? SuperHackersZeroHourExecutable + : SuperHackersGeneralsExecutable; + } + + /// + public string GetCommunityWorldBuilderExecutableName(SupportedGame managedGame) + { + return managedGame == SupportedGame.ZeroHour + ? SuperHackersZeroHourWorldBuilderExecutable + : SuperHackersGeneralsWorldBuilderExecutable; + } + + /// + public async Task StartAsync( + GameLaunchRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + string executableName = ResolveExecutableName(request); + string arguments = ResolveArguments(request); + + try + { + IProcessFamilyLaunchOperation operation = await _processFamilyLauncher.StartAsync( + executableName, + arguments, + cancellationToken).ConfigureAwait(false); + return new WindowsGameProcessLaunchOperation( + request.TargetKind, + executableName, + arguments, + operation, + _logger); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Failed to launch {ExecutableName}.", executableName); + throw; + } + } + + /// + public async Task LaunchAsync( + GameLaunchRequest request, + CancellationToken cancellationToken) + { + IGameProcessLaunchOperation operation = await StartAsync( + request, + cancellationToken).ConfigureAwait(false); + return await operation.Completion.ConfigureAwait(false); + } + + /// + /// Resolves the executable name for a launch request. + /// + /// The game launch request. + /// The executable name. + private string ResolveExecutableName(GameLaunchRequest request) + { + if (request.TargetKind == GameLaunchTargetKind.WorldBuilder) + { + return request.ExecutableName; + } + + return request.UseGeneralsOnline + ? GeneralsOnlineLauncherExecutable + : GetCommunityGameExecutableName(request.ManagedGame); + } + + /// + /// Resolves command-line arguments for a launch request. + /// + /// The game launch request. + /// The process arguments. + private static string ResolveArguments(GameLaunchRequest request) + { + return request.TargetKind == GameLaunchTargetKind.GameClient && request.UseGeneralsOnline + ? string.Empty + : request.Arguments; + } + + /// + /// Adapts an infrastructure process-family operation to the Core game-launch operation contract. + /// + private sealed class WindowsGameProcessLaunchOperation : IGameProcessLaunchOperation + { + /// + /// The target kind used for success-threshold evaluation. + /// + private readonly GameLaunchTargetKind _targetKind; + + /// + /// The command-line arguments used to start the process. + /// + private readonly string _arguments; + + /// + /// The tracked infrastructure process-family operation. + /// + private readonly IProcessFamilyLaunchOperation _processFamilyOperation; + + /// + /// The logger used for process launch diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The process target kind. + /// The executable name used to start the process. + /// The command-line arguments used to start the process. + /// The tracked process-family operation. + /// The logger used for process launch diagnostics. + public WindowsGameProcessLaunchOperation( + GameLaunchTargetKind targetKind, + string executableName, + string arguments, + IProcessFamilyLaunchOperation processFamilyOperation, + ILogger logger) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executableName); + + _targetKind = targetKind; + ExecutableName = executableName; + _arguments = arguments ?? string.Empty; + _processFamilyOperation = processFamilyOperation ?? + throw new ArgumentNullException(nameof(processFamilyOperation)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _processFamilyOperation.CurrentExecutableNameChanged += ProcessFamilyOperation_CurrentExecutableNameChanged; + Completion = CompleteAsync(); + } + + /// + public string ExecutableName { get; } + + /// + public string CurrentExecutableName => _processFamilyOperation.CurrentExecutableName; + + /// + public event EventHandler? CurrentExecutableNameChanged; + + /// + public Task Completion { get; } + + /// + public void ForceClose() + { + _processFamilyOperation.ForceClose(); + } + + /// + /// Converts the process-family completion into a game-launch result. + /// + /// The game-launch result. + private async Task CompleteAsync() + { + try + { + TimeSpan runningDuration = await _processFamilyOperation.Completion.ConfigureAwait(false); + if (_targetKind == GameLaunchTargetKind.GameClient && + runningDuration.TotalMilliseconds < SuccessfulLaunchThresholdMilliseconds) + { + const string message = "The game process exited before the successful launch threshold."; + _logger.LogInformation( + "Launch of {ExecutableName} ended after {RunningDurationMs}ms, below the success threshold.", + ExecutableName, + runningDuration.TotalMilliseconds); + return GameLaunchResult.Failure(ExecutableName, _arguments, runningDuration, message); + } + + return GameLaunchResult.Success(ExecutableName, _arguments, runningDuration); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogError(ex, "Failed while waiting for launched process {ExecutableName}.", ExecutableName); + throw; + } + finally + { + _processFamilyOperation.CurrentExecutableNameChanged -= ProcessFamilyOperation_CurrentExecutableNameChanged; + } + } + + /// + /// Relays process-family current executable changes through the game-launch operation contract. + /// + /// The process-family operation. + /// The event arguments. + private void ProcessFamilyOperation_CurrentExecutableNameChanged(object? sender, EventArgs e) + { + CurrentExecutableNameChanged?.Invoke(this, EventArgs.Empty); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/WindowsProcessFamilyLauncher.cs b/GenLauncherGO.Infrastructure/Launching/Services/WindowsProcessFamilyLauncher.cs new file mode 100644 index 00000000..87e03fa7 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/WindowsProcessFamilyLauncher.cs @@ -0,0 +1,1033 @@ +using System; +using System.ComponentModel; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Launching.Support; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Launching.Services; + +/// +/// Starts Windows processes and waits until the launched process family has exited. +/// +public sealed class WindowsProcessFamilyLauncher : IProcessFamilyLauncher +{ + /// + /// The interval used to poll the process table. + /// + private const int ProcessPollMilliseconds = 500; + + /// + /// The grace period used when a launcher process hands off to a child process. + /// + private const int ProcessHandoffGraceMilliseconds = 5000; + + /// + /// The ToolHelp snapshot flag for process snapshots. + /// + private const uint Th32csSnapprocess = 0x00000002; + + /// + /// The invalid native handle value. + /// + private static readonly IntPtr _invalidHandleValue = new(-1); + + /// + /// The logger used for process-family diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger used for process-family diagnostics. + public WindowsProcessFamilyLauncher(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public Task StartAsync( + string executableName, + string arguments, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executableName); + + return Task.Run( + () => StartLaunchOperation(executableName, arguments ?? string.Empty, cancellationToken), + cancellationToken); + } + + /// + public Task LaunchAndWaitForExitAsync( + string executableName, + string arguments, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executableName); + + return LaunchAndWaitForExitCoreAsync(executableName, arguments, cancellationToken); + } + + /// + /// Starts the executable and waits for the launched process family to exit. + /// + /// The executable name or path to launch. + /// The command-line arguments to pass to the executable. + /// A token that cancels the process-family wait operation. + /// The observed running duration for the launched process family. + private async Task LaunchAndWaitForExitCoreAsync( + string executableName, + string arguments, + CancellationToken cancellationToken) + { + IProcessFamilyLaunchOperation operation = await StartAsync( + executableName, + arguments, + cancellationToken).ConfigureAwait(false); + return await operation.Completion.ConfigureAwait(false); + } + + /// + /// Starts the executable and creates its process-family operation. + /// + /// The executable name or path to launch. + /// The command-line arguments to pass to the executable. + /// A token that cancels the wait operation. + /// The tracked process-family launch operation. + private IProcessFamilyLaunchOperation StartLaunchOperation( + string executableName, + string arguments, + CancellationToken cancellationToken) + { + Process process = StartExecutable(executableName, arguments); + return new WindowsProcessFamilyLaunchOperation( + executableName, + process, + new ProcessFamilyTracker(process.Id, executableName, _logger), + cancellationToken, + _logger); + } + + /// + /// Starts the executable with arguments. + /// + /// The executable name or path to launch. + /// The command-line arguments to pass to the executable. + /// The started process. + /// Thrown when the process could not be started. + private static Process StartExecutable(string executableName, string arguments) + { + var process = Process.Start(executableName, arguments); + if (process == null) + { + throw new InvalidOperationException($"Failed to start {executableName}."); + } + + return process; + } + + /// + /// Attempts to capture a snapshot of running Windows processes. + /// + /// The process snapshot entries, or when the snapshot cannot be captured. + private static IReadOnlyList? TryCaptureProcessSnapshot() + { + IntPtr snapshotHandle = CreateToolhelp32Snapshot(Th32csSnapprocess, 0); + if (snapshotHandle == _invalidHandleValue) + { + return null; + } + + try + { + var entries = new List(); + var nativeEntry = new NativeProcessEntry + { + Size = (uint)Marshal.SizeOf(typeof(NativeProcessEntry)), + }; + if (!Process32First(snapshotHandle, ref nativeEntry)) + { + return entries; + } + + do + { + entries.Add(new ProcessSnapshotEntry( + unchecked((int)nativeEntry.ProcessId), + unchecked((int)nativeEntry.ParentProcessId), + nativeEntry.ExecutableFileName ?? string.Empty)); + } while (Process32Next(snapshotHandle, ref nativeEntry)); + + return entries; + } + finally + { + CloseHandle(snapshotHandle); + } + } + + /// + /// Captures a native ToolHelp snapshot. + /// + /// The ToolHelp snapshot flags. + /// The target process id, or zero for all processes. + /// The native snapshot handle. + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint processId); + + /// + /// Reads the first process entry from a native process snapshot. + /// + /// The native snapshot handle. + /// The process entry buffer. + /// when a process entry was read; otherwise, . + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] + private static extern bool Process32First(IntPtr snapshotHandle, ref NativeProcessEntry processEntry); + + /// + /// Reads the next process entry from a native process snapshot. + /// + /// The native snapshot handle. + /// The process entry buffer. + /// when a process entry was read; otherwise, . + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] + private static extern bool Process32Next(IntPtr snapshotHandle, ref NativeProcessEntry processEntry); + + /// + /// Closes a native handle. + /// + /// The native handle. + /// when the handle was closed; otherwise, . + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); + + /// + /// Observes a started Windows process family and exposes a force-close command for it. + /// + private sealed class WindowsProcessFamilyLaunchOperation : IProcessFamilyLaunchOperation + { + /// + /// The logger used for process-family diagnostics. + /// + private readonly ILogger _logger; + + /// + /// The root process object returned by . + /// + private readonly Process _rootProcess; + + /// + /// The process-family tracker. + /// + private readonly ProcessFamilyTracker _processFamily; + + /// + /// A token that cancels the wait operation. + /// + private readonly CancellationToken _cancellationToken; + + /// + /// Synchronizes root process disposal. + /// + private readonly object _syncRoot = new(); + + /// + /// A value indicating whether the root process object has been disposed. + /// + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + /// The executable name used to start the process family. + /// The root process returned from process start. + /// The process-family tracker. + /// A token that cancels the wait operation. + /// The logger used for process-family diagnostics. + public WindowsProcessFamilyLaunchOperation( + string executableName, + Process rootProcess, + ProcessFamilyTracker processFamily, + CancellationToken cancellationToken, + ILogger logger) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executableName); + + ExecutableName = executableName; + _rootProcess = rootProcess ?? throw new ArgumentNullException(nameof(rootProcess)); + _processFamily = processFamily ?? throw new ArgumentNullException(nameof(processFamily)); + _cancellationToken = cancellationToken; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + Completion = Task.Run(WaitForProcessFamilyExit); + } + + /// + public string ExecutableName { get; } + + /// + public string CurrentExecutableName => _processFamily.CurrentExecutableName; + + /// + public event EventHandler? CurrentExecutableNameChanged; + + /// + public Task Completion { get; } + + /// + public void ForceClose() + { + _logger.LogWarning( + "Force close requested for launched process family {ExecutableName}.", + ExecutableName); + _processFamily.ForceClose(); + } + + /// + /// Waits until the tracked process family has exited. + /// + /// The observed running duration for the launched process family. + private TimeSpan WaitForProcessFamilyExit() + { + string currentExecutableName = CurrentExecutableName; + try + { + while (_processFamily.IsRunning()) + { + RaiseCurrentExecutableNameChangedIfNeeded(ref currentExecutableName); + if (_cancellationToken.WaitHandle.WaitOne(ProcessPollMilliseconds)) + { + _cancellationToken.ThrowIfCancellationRequested(); + } + } + + RaiseCurrentExecutableNameChangedIfNeeded(ref currentExecutableName); + return _processFamily.RunningDuration; + } + finally + { + DisposeRootProcess(); + } + } + + /// + /// Raises when the tracker reports a new current executable. + /// + /// The last executable name raised by this operation. + private void RaiseCurrentExecutableNameChangedIfNeeded(ref string currentExecutableName) + { + string updatedExecutableName = CurrentExecutableName; + if (String.Equals(updatedExecutableName, currentExecutableName, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + currentExecutableName = updatedExecutableName; + CurrentExecutableNameChanged?.Invoke(this, EventArgs.Empty); + } + + /// + /// Releases the root process object after tracking is complete. + /// + private void DisposeRootProcess() + { + lock (_syncRoot) + { + if (_disposed) + { + return; + } + + _rootProcess.Dispose(); + _disposed = true; + } + } + } + + /// + /// Describes one native process entry returned by ToolHelp. + /// + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] + private struct NativeProcessEntry + { + /// + /// The size of this structure. + /// + public uint Size; + + /// + /// The usage count reported by ToolHelp. + /// + public uint UsageCount; + + /// + /// The process identifier. + /// + public uint ProcessId; + + /// + /// The default heap identifier. + /// + public IntPtr DefaultHeapId; + + /// + /// The module identifier. + /// + public uint ModuleId; + + /// + /// The thread count. + /// + public uint ThreadCount; + + /// + /// The parent process identifier. + /// + public uint ParentProcessId; + + /// + /// The base priority class. + /// + public int PriorityClassBase; + + /// + /// The native entry flags. + /// + public uint Flags; + + /// + /// The executable file name. + /// + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] + public string? ExecutableFileName; + } + + /// + /// Tracks the root process and descendants until they have all exited. + /// + internal sealed class ProcessFamilyTracker + { + /// + /// Captures the current Windows process table. + /// + private readonly Func?> _captureProcessSnapshot; + + /// + /// Determines whether a process id is currently running when snapshots are unavailable. + /// + private readonly Func _isProcessRunning; + + /// + /// Reads the current UTC time. + /// + private readonly Func _getUtcNow; + + /// + /// Force closes a running process id. + /// + private readonly Action _forceCloseProcess; + + /// + /// Synchronizes tracked process state. + /// + private readonly object _syncRoot = new(); + + /// + /// The grace period used to keep retired process ids available for handoff detection. + /// + private readonly TimeSpan _handoffGracePeriod; + + /// + /// The process ids that exited recently and may still parent a handoff process. + /// + private readonly Dictionary _retiredProcessIds = new(); + + /// + /// The known process ids in the launched process family. + /// + private readonly HashSet _knownProcessIds = new(); + + /// + /// The executable names observed for known process ids. + /// + private readonly Dictionary _knownProcessNames = new(); + + /// + /// The observed descendant depth for known process ids. + /// + private readonly Dictionary _knownProcessDepths = new(); + + /// + /// The order in which known process ids were first observed. + /// + private readonly Dictionary _knownProcessOrders = new(); + + /// + /// The root process id. + /// + private readonly int _rootProcessId; + + /// + /// The time when tracking started. + /// + private readonly DateTime _startedAtUtc; + + /// + /// The logger used for process-family diagnostics. + /// + private readonly ILogger _logger; + + /// + /// The time when the process family first appeared empty after a child process was observed. + /// + private DateTime? _emptyFamilyObservedAtUtc; + + /// + /// The most recent time a known process was observed running. + /// + private DateTime _lastObservedRunningAtUtc; + + /// + /// A value indicating whether a child process was ever observed. + /// + private bool _childProcessObserved; + + /// + /// A value indicating whether snapshot failure fallback was already logged. + /// + private bool _snapshotFailureLogged; + + /// + /// The executable name for the current tracked running process. + /// + private string _currentExecutableName; + + /// + /// The next process observation order value. + /// + private int _nextProcessOrder; + + /// + /// Initializes a new instance of the class. + /// + /// The root process id. + /// The executable name used to start the root process. + /// The logger used for process-family diagnostics. + public ProcessFamilyTracker( + int rootProcessId, + string rootExecutableName, + ILogger logger) + : this( + rootProcessId, + rootExecutableName, + logger, + TryCaptureProcessSnapshot, + IsProcessRunning, + () => DateTime.UtcNow, + TimeSpan.FromMilliseconds(ProcessHandoffGraceMilliseconds), + ForceCloseProcess) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The root process id. + /// The logger used for process-family diagnostics. + public ProcessFamilyTracker( + int rootProcessId, + ILogger logger) + : this( + rootProcessId, + string.Empty, + logger) + { + } + + /// + /// Initializes a new instance of the class with testable adapters. + /// + /// The root process id. + /// The logger used for process-family diagnostics. + /// Captures the current process table. + /// Determines whether a process id is still running. + /// Reads the current UTC time. + /// The handoff grace period. + /// Force closes a running process id. + internal ProcessFamilyTracker( + int rootProcessId, + ILogger logger, + Func?> captureProcessSnapshot, + Func isProcessRunning, + Func getUtcNow, + TimeSpan handoffGracePeriod, + Action forceCloseProcess) + : this( + rootProcessId, + string.Empty, + logger, + captureProcessSnapshot, + isProcessRunning, + getUtcNow, + handoffGracePeriod, + forceCloseProcess) + { + } + + /// + /// Initializes a new instance of the class with testable adapters. + /// + /// The root process id. + /// The logger used for process-family diagnostics. + /// Captures the current process table. + /// Determines whether a process id is still running. + /// Reads the current UTC time. + /// The handoff grace period. + internal ProcessFamilyTracker( + int rootProcessId, + ILogger logger, + Func?> captureProcessSnapshot, + Func isProcessRunning, + Func getUtcNow, + TimeSpan handoffGracePeriod) + : this( + rootProcessId, + string.Empty, + logger, + captureProcessSnapshot, + isProcessRunning, + getUtcNow, + handoffGracePeriod, + ForceCloseProcess) + { + } + + /// + /// Initializes a new instance of the class with testable adapters. + /// + /// The root process id. + /// The executable name used to start the root process. + /// The logger used for process-family diagnostics. + /// Captures the current process table. + /// Determines whether a process id is still running. + /// Reads the current UTC time. + /// The handoff grace period. + /// Force closes a running process id. + internal ProcessFamilyTracker( + int rootProcessId, + string rootExecutableName, + ILogger logger, + Func?> captureProcessSnapshot, + Func isProcessRunning, + Func getUtcNow, + TimeSpan handoffGracePeriod, + Action forceCloseProcess) + { + _rootProcessId = rootProcessId; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _captureProcessSnapshot = captureProcessSnapshot ?? + throw new ArgumentNullException(nameof(captureProcessSnapshot)); + _isProcessRunning = isProcessRunning ?? throw new ArgumentNullException(nameof(isProcessRunning)); + _getUtcNow = getUtcNow ?? throw new ArgumentNullException(nameof(getUtcNow)); + _handoffGracePeriod = handoffGracePeriod; + _forceCloseProcess = forceCloseProcess ?? throw new ArgumentNullException(nameof(forceCloseProcess)); + _startedAtUtc = _getUtcNow(); + _lastObservedRunningAtUtc = _startedAtUtc; + _currentExecutableName = NormalizeExecutableName(rootExecutableName); + _knownProcessIds.Add(rootProcessId); + _knownProcessNames[rootProcessId] = _currentExecutableName; + _knownProcessDepths[rootProcessId] = 0; + _knownProcessOrders[rootProcessId] = _nextProcessOrder++; + } + + /// + /// Gets the observed running duration for the launched process family. + /// + public TimeSpan RunningDuration => _lastObservedRunningAtUtc - _startedAtUtc; + + /// + /// Gets the executable name for the current tracked running process. + /// + public string CurrentExecutableName + { + get + { + lock (_syncRoot) + { + return _currentExecutableName; + } + } + } + + /// + /// Determines whether the tracked process family should still be considered running. + /// + /// when the process family is still running; otherwise, . + public bool IsRunning() + { + lock (_syncRoot) + { + return IsRunningCore(); + } + } + + /// + /// Force closes all currently tracked running processes. + /// + public void ForceClose() + { + IReadOnlyList processIds; + lock (_syncRoot) + { + processIds = GetTrackedRunningProcessIds(); + } + + foreach (int processId in processIds) + { + TryForceCloseProcess(processId); + } + } + + /// + /// Determines whether the tracked process family should still be considered running. + /// + /// when the process family is still running; otherwise, . + private bool IsRunningCore() + { + IReadOnlyList? entries = _captureProcessSnapshot(); + if (entries == null) + { + LogSnapshotFailureOnce(); + return IsRootProcessRunning(); + } + + DateTime nowUtc = _getUtcNow(); + ExpireRetiredProcessIds(nowUtc); + TrackDescendants(entries); + + var runningProcessIds = entries + .Select(entry => entry.ProcessId) + .ToHashSet(); + + IReadOnlyList knownRunningProcessIds = UpdateKnownProcessState(runningProcessIds, nowUtc); + UpdateCurrentExecutableName(knownRunningProcessIds); + if (HasActiveTrackedProcess(knownRunningProcessIds)) + { + _emptyFamilyObservedAtUtc = null; + _lastObservedRunningAtUtc = nowUtc; + return true; + } + + if (!_childProcessObserved) + { + return false; + } + + _emptyFamilyObservedAtUtc ??= nowUtc; + return nowUtc - _emptyFamilyObservedAtUtc.Value < _handoffGracePeriod; + } + + /// + /// Gets tracked process ids that are currently running. + /// + /// The tracked running process ids. + private IReadOnlyList GetTrackedRunningProcessIds() + { + IReadOnlyList? entries = _captureProcessSnapshot(); + if (entries == null) + { + LogSnapshotFailureOnce(); + return _knownProcessIds + .Where(_isProcessRunning) + .ToList(); + } + + TrackDescendants(entries); + var runningProcessIds = entries + .Select(entry => entry.ProcessId) + .ToHashSet(); + return _knownProcessIds + .Where(processId => !_retiredProcessIds.ContainsKey(processId)) + .Where(runningProcessIds.Contains) + .ToList(); + } + + /// + /// Attempts to force close a tracked process id. + /// + /// The tracked process id. + private void TryForceCloseProcess(int processId) + { + try + { + _forceCloseProcess(processId); + _logger.LogWarning("Force closed launched process {ProcessId}.", processId); + } + catch (ArgumentException) + { + _logger.LogInformation( + "Tracked launched process {ProcessId} exited before force close completed.", + processId); + } + catch (InvalidOperationException) + { + _logger.LogInformation( + "Tracked launched process {ProcessId} exited before force close completed.", + processId); + } + catch (Win32Exception ex) + { + _logger.LogWarning(ex, "Failed to force close launched process {ProcessId}.", processId); + } + catch (NotSupportedException ex) + { + _logger.LogWarning(ex, "Failed to force close launched process {ProcessId}.", processId); + } + } + + /// + /// Removes retired process ids after the handoff grace period expires. + /// + /// The current UTC time. + private void ExpireRetiredProcessIds(DateTime nowUtc) + { + foreach (KeyValuePair retiredProcess in _retiredProcessIds.ToList()) + { + if (nowUtc - retiredProcess.Value < _handoffGracePeriod) + { + continue; + } + + _retiredProcessIds.Remove(retiredProcess.Key); + _knownProcessIds.Remove(retiredProcess.Key); + _knownProcessNames.Remove(retiredProcess.Key); + _knownProcessDepths.Remove(retiredProcess.Key); + _knownProcessOrders.Remove(retiredProcess.Key); + } + } + + /// + /// Adds observed descendants of known process ids to the tracked process family. + /// + /// The current process snapshot entries. + private void TrackDescendants(IReadOnlyList entries) + { + bool addedProcess; + do + { + addedProcess = false; + foreach (ProcessSnapshotEntry entry in entries) + { + if (!_knownProcessIds.Contains(entry.ParentProcessId) || + !_knownProcessIds.Add(entry.ProcessId)) + { + continue; + } + + addedProcess = true; + _childProcessObserved = true; + _retiredProcessIds.Remove(entry.ProcessId); + _knownProcessNames[entry.ProcessId] = NormalizeExecutableName(entry.ExecutableFileName); + _knownProcessDepths[entry.ProcessId] = GetProcessDepth(entry.ParentProcessId) + 1; + _knownProcessOrders[entry.ProcessId] = _nextProcessOrder++; + + _logger.LogInformation( + "Tracking launched child process {ProcessId} ({ExecutableName}) for cleanup wait.", + entry.ProcessId, + _knownProcessNames[entry.ProcessId]); + } + } while (addedProcess); + } + + /// + /// Updates known process running and retirement state from the current snapshot. + /// + /// The process ids currently running. + /// The current UTC time. + /// The known process ids that are currently running. + private IReadOnlyList UpdateKnownProcessState( + HashSet runningProcessIds, + DateTime nowUtc) + { + var knownRunningProcessIds = new List(); + foreach (int processId in _knownProcessIds) + { + if (runningProcessIds.Contains(processId) && !_retiredProcessIds.ContainsKey(processId)) + { + knownRunningProcessIds.Add(processId); + _retiredProcessIds.Remove(processId); + continue; + } + + if (!_retiredProcessIds.ContainsKey(processId)) + { + _retiredProcessIds[processId] = nowUtc; + } + } + + return knownRunningProcessIds; + } + + /// + /// Determines whether the current running ids represent an active tracked process. + /// + /// The known process ids currently running. + /// when an active tracked process is running; otherwise, . + private bool HasActiveTrackedProcess(IReadOnlyList knownRunningProcessIds) + { + if (!_childProcessObserved) + { + return knownRunningProcessIds.Contains(_rootProcessId); + } + + return knownRunningProcessIds.Any(processId => processId != _rootProcessId); + } + + /// + /// Updates the executable name shown for the current tracked running process. + /// + /// The known process ids currently running. + private void UpdateCurrentExecutableName(IReadOnlyList knownRunningProcessIds) + { + IEnumerable candidates = _childProcessObserved + ? knownRunningProcessIds.Where(processId => processId != _rootProcessId) + : knownRunningProcessIds; + int? currentProcessId = candidates + .OrderByDescending(GetProcessDepth) + .ThenByDescending(GetProcessOrder) + .Cast() + .FirstOrDefault(); + if (!currentProcessId.HasValue) + { + return; + } + + if (_knownProcessNames.TryGetValue(currentProcessId.Value, out string? executableName) && + !String.IsNullOrWhiteSpace(executableName)) + { + _currentExecutableName = executableName; + } + } + + /// + /// Gets the observed descendant depth for a known process id. + /// + /// The known process id. + /// The observed descendant depth. + private int GetProcessDepth(int processId) + { + return _knownProcessDepths.TryGetValue(processId, out int depth) + ? depth + : 0; + } + + /// + /// Gets the observation order for a known process id. + /// + /// The known process id. + /// The process observation order. + private int GetProcessOrder(int processId) + { + return _knownProcessOrders.TryGetValue(processId, out int order) + ? order + : 0; + } + + /// + /// Determines whether the root process is still running when process snapshots are unavailable. + /// + /// when the root process is running; otherwise, . + private bool IsRootProcessRunning() + { + if (!_isProcessRunning(_rootProcessId)) + { + return false; + } + + if (_knownProcessNames.TryGetValue(_rootProcessId, out string? executableName) && + !String.IsNullOrWhiteSpace(executableName)) + { + _currentExecutableName = executableName; + } + + _lastObservedRunningAtUtc = _getUtcNow(); + return true; + } + + /// + /// Logs the process snapshot fallback warning once. + /// + private void LogSnapshotFailureOnce() + { + if (_snapshotFailureLogged) + { + return; + } + + _logger.LogWarning( + "Could not inspect launched child processes; falling back to the root launch process only."); + _snapshotFailureLogged = true; + } + + /// + /// Normalizes an executable name for display and comparison. + /// + /// The executable name to normalize. + /// The normalized executable name. + private static string NormalizeExecutableName(string? executableName) + { + return executableName?.Trim() ?? string.Empty; + } + } + + /// + /// Describes one process snapshot entry. + /// + /// The process identifier. + /// The parent process identifier. + /// The executable file name. + internal sealed record ProcessSnapshotEntry( + int ProcessId, + int ParentProcessId, + string ExecutableFileName = ""); + + /// + /// Determines whether the requested process id is still running. + /// + /// The process id to inspect. + /// when the process is still running. + private static bool IsProcessRunning(int processId) + { + try + { + using var process = Process.GetProcessById(processId); + return !process.HasExited; + } + catch (ArgumentException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } + + /// + /// Force closes the requested process id and any descendants still attached to it. + /// + /// The process id to force close. + private static void ForceCloseProcess(int processId) + { + using var process = Process.GetProcessById(processId); + if (!process.HasExited) + { + process.Kill(entireProcessTree: true); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/DeploymentFilePlanner.cs b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentFilePlanner.cs new file mode 100644 index 00000000..0c6e2691 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentFilePlanner.cs @@ -0,0 +1,102 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Infrastructure.Common; + +namespace GenLauncherGO.Infrastructure.Launching.Support; + +/// +/// Resolves package files and directories for deployment without mutating the filesystem. +/// +internal static class DeploymentFilePlanner +{ + /// + /// Resolves package files and applies package precedence. + /// + /// The deployment request. + /// The files to deploy. + public static IReadOnlyList ResolveDeploymentFiles(DeploymentRequest request) + { + var filesByTarget = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (DeploymentPackage package in request.Packages.OrderBy(package => package.Precedence)) + { + string packageRoot = Path.GetFullPath(package.RootDirectory); + if (!Directory.Exists(packageRoot)) + { + throw new DirectoryNotFoundException( + $"Deployment package directory was not found: {package.RootDirectory}"); + } + + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + packageRoot, + "Deployment package paths must be rooted.", + "Deployment package directories must not contain reparse points."); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + packageRoot, + "Deployment package directories must not contain reparse points."); + + foreach (string sourcePath in Directory.EnumerateFiles( + packageRoot, + "*", + FileSystemPathSafety.CreateRecursiveNoLinksOptions())) + { + string extension = Path.GetExtension(sourcePath); + if (string.Equals(extension, ".exe", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".dll", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + string relativePath = DeploymentPathResolver.ToRelativeManifestPath(packageRoot, sourcePath); + string targetRelativePath = string.Equals(extension, ".gib", StringComparison.OrdinalIgnoreCase) + ? Path.ChangeExtension(relativePath, ".big").Replace('\\', '/') + : relativePath; + string normalizedTargetPath = DeploymentPathResolver.NormalizeManifestPath(targetRelativePath); + filesByTarget[normalizedTargetPath] = new ResolvedDeploymentFile( + sourcePath, + normalizedTargetPath, + package.Id, + package.Precedence); + } + } + + return filesByTarget.Values.OrderBy(file => file.TargetRelativePath, StringComparer.OrdinalIgnoreCase).ToList(); + } + + /// + /// Returns directories between a game root and target directory that do not already exist. + /// + /// The game root directory. + /// The target directory to create. + /// The directories to create in parent-first order. + public static IEnumerable GetDirectoriesToCreate(string gameRoot, string targetDirectory) + { + var directories = new Stack(); + string root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(gameRoot)); + string? current = Path.GetFullPath(targetDirectory); + while (!string.IsNullOrWhiteSpace(current) && + !string.Equals(Path.TrimEndingDirectorySeparator(current), root, StringComparison.OrdinalIgnoreCase) && + !Directory.Exists(current)) + { + directories.Push(current); + current = Directory.GetParent(current)?.FullName; + } + + return directories; + } +} + +/// +/// Describes a resolved file to deploy. +/// +/// The source file path. +/// The target relative path. +/// The package identifier. +/// The package precedence. +internal sealed record ResolvedDeploymentFile( + string SourcePath, + string TargetRelativePath, + string PackageId, + int Precedence); diff --git a/GenLauncherGO.Infrastructure/Launching/Support/DeploymentPathResolver.cs b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentPathResolver.cs new file mode 100644 index 00000000..7f2beb11 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentPathResolver.cs @@ -0,0 +1,97 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; + +namespace GenLauncherGO.Infrastructure.Launching.Support; + +/// +/// Resolves deployment manifest paths inside launcher-owned deployment roots. +/// +internal static class DeploymentPathResolver +{ + /// + /// Resolves a game-directory-relative manifest path. + /// + /// The launcher paths. + /// The relative manifest path. + /// The full game path. + public static string ResolveGamePath(LauncherPaths paths, string relativePath) + { + string normalizedPath = NormalizeManifestPath(relativePath); + string gameRoot = Path.GetFullPath(paths.GameDirectory); + string candidatePath = Path.GetFullPath(Path.Combine(gameRoot, normalizedPath)); + string launcherRoot = Path.GetFullPath(paths.LauncherDirectory); + + if (!FileSystemPathSafety.IsPathInDirectory(candidatePath, gameRoot) || + FileSystemPathSafety.IsPathInDirectory(candidatePath, launcherRoot)) + { + throw new InvalidDataException($"Deployment target path '{relativePath}' is outside the game directory."); + } + + return candidatePath; + } + + /// + /// Normalizes a manifest path to slash separators after validation. + /// + /// The relative path to normalize. + /// The normalized manifest path. + public static string NormalizeManifestPath(string relativePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); + if (Path.IsPathRooted(relativePath) || relativePath.Contains(':', StringComparison.Ordinal)) + { + throw new InvalidDataException("Deployment manifest paths must be relative."); + } + + string[] segments = relativePath.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) + { + throw new InvalidDataException("Deployment manifest paths must include a file name."); + } + + foreach (string segment in segments) + { + if (string.Equals(segment, ".", StringComparison.Ordinal) || + string.Equals(segment, "..", StringComparison.Ordinal)) + { + throw new InvalidDataException("Deployment manifest paths must not contain parent directory segments."); + } + } + + return string.Join('/', segments); + } + + /// + /// Converts a full path to a normalized manifest-relative path. + /// + /// The root directory. + /// The child path. + /// The manifest-relative path. + public static string ToRelativeManifestPath(string rootDirectory, string path) + { + return NormalizeManifestPath(Path.GetRelativePath(Path.GetFullPath(rootDirectory), Path.GetFullPath(path))); + } + + /// + /// Resolves a deployment-state-relative path. + /// + /// The deployment state directory. + /// The deployment-state-relative path. + /// The full deployment state path. + public static string ResolveDeploymentStatePath(string deploymentDirectory, string relativePath) + { + string normalizedPath = NormalizeManifestPath(relativePath); + string deploymentRoot = Path.GetFullPath(deploymentDirectory); + string candidatePath = Path.GetFullPath(Path.Combine(deploymentRoot, normalizedPath)); + + if (!FileSystemPathSafety.IsPathInDirectory(candidatePath, deploymentRoot)) + { + throw new InvalidDataException( + $"Deployment state path '{relativePath}' is outside the deployment directory."); + } + + return candidatePath; + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/DeploymentStateStore.cs b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentStateStore.cs new file mode 100644 index 00000000..6e8690b1 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentStateStore.cs @@ -0,0 +1,717 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Launching.Support; + +/// +/// Persists deployment manifest and journal state used to recover file-system deployment side effects. +/// +internal sealed class DeploymentStateStore +{ + /// + /// The backup root directory name. + /// + public const string BackupsDirectoryName = "Backups"; + + /// + /// The manifest schema version written by this store. + /// + private const int SchemaVersion = 1; + + /// + /// The completed deployment manifest file name. + /// + private const string ActiveManifestFileName = "active.json"; + + /// + /// The append-only journal file name. + /// + private const string JournalFileName = "journal.jsonl"; + + /// + /// The deployment operation lock file name. + /// + private const string LockFileName = "deployment.lock"; + + /// + /// The JSON serialization options used for manifests and journal records. + /// + private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web) + { + WriteIndented = true + }; + + /// + /// The compact JSON serialization options used for one-record-per-line journal entries. + /// + private static readonly JsonSerializerOptions _journalJsonOptions = new(JsonSerializerDefaults.Web); + + /// + /// The logger used for deployment state diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger used for deployment state diagnostics. + public DeploymentStateStore(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Creates deployment state paths for a deployment id. + /// + /// The launcher paths. + /// The deployment id, when a deployment-specific path is needed. + /// The deployment state paths. + public static DeploymentStatePaths CreatePaths(LauncherPaths paths, string deploymentId) + { + string deploymentDirectory = paths.DeploymentDirectory; + string backupDirectory = string.IsNullOrWhiteSpace(deploymentId) + ? Path.Combine(deploymentDirectory, BackupsDirectoryName) + : Path.Combine(deploymentDirectory, BackupsDirectoryName, deploymentId); + + return new DeploymentStatePaths( + deploymentDirectory, + Path.Combine(deploymentDirectory, ActiveManifestFileName), + Path.Combine(deploymentDirectory, JournalFileName), + Path.Combine(deploymentDirectory, LockFileName), + backupDirectory); + } + + /// + /// Acquires an exclusive deployment operation lock. + /// + /// The launcher paths. + /// The open lock file stream. + public static FileStream AcquireDeploymentLock(LauncherPaths paths) + { + DeploymentStatePaths deploymentPaths = CreatePaths(paths, deploymentId: string.Empty); + Directory.CreateDirectory(deploymentPaths.DeploymentDirectory); + return new FileStream( + deploymentPaths.LockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None); + } + + /// + /// Writes a completed deployment manifest. + /// + /// The manifest path. + /// The manifest document. + public static void WriteManifest(string manifestPath, DeploymentManifestDocument manifest) + { + string manifestDirectory = Path.GetDirectoryName(manifestPath) ?? string.Empty; + Directory.CreateDirectory(manifestDirectory); + string temporaryPath = Path.Combine( + manifestDirectory, + Path.GetFileName(manifestPath) + "." + Guid.NewGuid().ToString("N") + ".tmp"); + + try + { + WriteAllTextDurably(temporaryPath, JsonSerializer.Serialize(manifest, _jsonOptions)); + File.Move(temporaryPath, manifestPath, overwrite: true); + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + + /// + /// Deletes persisted active deployment state after cleanup or recovery. + /// + /// The deployment state paths. + public static void DeleteDeploymentStateFiles(DeploymentStatePaths paths) + { + if (File.Exists(paths.ActiveManifestPath)) + { + File.Delete(paths.ActiveManifestPath); + } + + if (File.Exists(paths.JournalPath)) + { + File.Delete(paths.JournalPath); + } + } + + /// + /// Appends one journal record. + /// + /// The journal path. + /// The record to append. + public static void AppendJournal(string journalPath, DeploymentJournalRecord record) + { + Directory.CreateDirectory(Path.GetDirectoryName(journalPath) ?? string.Empty); + byte[] bytes = Encoding.UTF8.GetBytes( + JsonSerializer.Serialize(record, _journalJsonOptions) + Environment.NewLine); + using FileStream stream = new( + journalPath, + FileMode.Append, + FileAccess.Write, + FileShare.Read, + bufferSize: 4096, + FileOptions.WriteThrough); + stream.Write(bytes, 0, bytes.Length); + stream.Flush(flushToDisk: true); + } + + /// + /// Converts an infrastructure manifest document to a Core manifest model. + /// + /// The manifest document. + /// The Core manifest. + public static DeploymentManifest ToCoreManifest(DeploymentManifestDocument document) + { + return new DeploymentManifest( + document.SchemaVersion, + document.DeploymentId, + document.CreatedAtUtc, + document.Files + .Select(file => new DeploymentFileEntry( + file.SourcePath, + file.TargetRelativePath, + file.Method, + file.BackupRelativePath, + file.PackageId)) + .ToList(), + document.CreatedDirectories); + } + + /// + /// Reads the active manifest or reconstructs it from the deployment journal. + /// + /// The deployment state paths. + /// The active deployment manifest, or when no state exists. + public DeploymentManifestDocument? ReadManifestOrJournal(DeploymentStatePaths paths) + { + DeploymentManifestDocument? manifest = TryReadManifest(paths.ActiveManifestPath, out Exception? readException); + if (File.Exists(paths.JournalPath)) + { + DeploymentManifestDocument? journalManifest = RebuildManifestFromJournal(paths); + if (journalManifest is not null) + { + return journalManifest; + } + + if (manifest is null && readException is not null) + { + throw new InvalidDataException( + "The deployment manifest could not be read and the journal did not contain recoverable deployment state.", + readException); + } + + return manifest; + } + + if (manifest is not null) + { + return manifest; + } + + if (readException is not null) + { + throw new InvalidDataException( + "The deployment manifest could not be read and no journal was available for recovery.", + readException); + } + + return null; + } + + /// + /// Writes text to a file and flushes it to disk before returning. + /// + /// The file path to write. + /// The text contents to write. + private static void WriteAllTextDurably(string path, string contents) + { + byte[] bytes = Encoding.UTF8.GetBytes(contents); + using FileStream stream = new( + path, + FileMode.Create, + FileAccess.Write, + FileShare.Read, + bufferSize: 4096, + FileOptions.WriteThrough); + stream.Write(bytes, 0, bytes.Length); + stream.Flush(flushToDisk: true); + } + + /// + /// Tries to read a completed deployment manifest without preventing journal fallback. + /// + /// The manifest path. + /// The manifest read exception, when one occurred. + /// The manifest document, or when no manifest could be read. + private DeploymentManifestDocument? TryReadManifest(string manifestPath, out Exception? readException) + { + readException = null; + if (!File.Exists(manifestPath)) + { + return null; + } + + try + { + DeploymentManifestDocument? manifest = + JsonSerializer.Deserialize(File.ReadAllText(manifestPath), _jsonOptions); + if (manifest is null) + { + readException = new InvalidDataException("The deployment manifest did not contain manifest data."); + _logger.LogWarning( + "Deployment manifest did not contain manifest data; journal recovery will be attempted."); + } + + return manifest; + } + catch (Exception ex) when (ex is IOException or JsonException or NotSupportedException) + { + readException = ex; + _logger.LogWarning(ex, "Deployment manifest could not be read; journal recovery will be attempted."); + return null; + } + } + + /// + /// Rebuilds a manifest from the journal. + /// + /// The deployment state paths. + /// The rebuilt manifest, or when no deploy-affecting records exist. + private DeploymentManifestDocument? RebuildManifestFromJournal(DeploymentStatePaths paths) + { + var filesByTarget = new Dictionary(StringComparer.OrdinalIgnoreCase); + var backupsByTarget = new Dictionary(StringComparer.OrdinalIgnoreCase); + var backupStartsByTarget = new Dictionary(StringComparer.OrdinalIgnoreCase); + var cleanupRestoreStartsByTarget = new Dictionary(StringComparer.OrdinalIgnoreCase); + var directories = new HashSet(StringComparer.OrdinalIgnoreCase); + bool sawDeploymentStateRecord = false; + + foreach (string line in File.ReadLines(paths.JournalPath)) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + DeploymentJournalRecord? record; + try + { + record = JsonSerializer.Deserialize(line, _journalJsonOptions); + } + catch (JsonException ex) + { + _logger.LogWarning(ex, "Skipped unreadable deployment journal record."); + continue; + } + + if (record is null) + { + continue; + } + + if (string.IsNullOrWhiteSpace(record.TargetRelativePath)) + { + _logger.LogWarning("Skipped deployment journal record without a target path."); + continue; + } + + if (string.Equals(record.Action, DeploymentJournalRecord.DirectoryCreatedAction, StringComparison.Ordinal)) + { + sawDeploymentStateRecord = true; + directories.Add(record.TargetRelativePath); + } + else if (string.Equals( + record.Action, + DeploymentJournalRecord.FileBackupStartedAction, + StringComparison.Ordinal)) + { + sawDeploymentStateRecord = true; + if (!string.IsNullOrWhiteSpace(record.BackupRelativePath)) + { + backupStartsByTarget[record.TargetRelativePath] = record.BackupRelativePath; + backupsByTarget[record.TargetRelativePath] = record.BackupRelativePath; + } + } + else if (string.Equals( + record.Action, + DeploymentJournalRecord.FileBackedUpAction, + StringComparison.Ordinal)) + { + sawDeploymentStateRecord = true; + backupsByTarget[record.TargetRelativePath] = record.BackupRelativePath ?? string.Empty; + filesByTarget[record.TargetRelativePath] = new DeploymentFileDocument( + SourcePath: "(recovered)", + record.TargetRelativePath, + DeploymentMethod.Copy, + record.BackupRelativePath, + PackageId: "(recovered)", + Size: 0, + LastWriteTimeUtc: DateTime.MinValue); + } + else if (string.Equals( + record.Action, + DeploymentJournalRecord.FileDeploymentStartedAction, + StringComparison.Ordinal)) + { + sawDeploymentStateRecord = true; + string targetRelativePath = record.TargetRelativePath ?? string.Empty; + backupsByTarget.TryGetValue(targetRelativePath, out string? backupRelativePath); + filesByTarget[targetRelativePath] = new DeploymentFileDocument( + record.SourcePath ?? "(recovered)", + targetRelativePath, + DeploymentMethod.Copy, + record.BackupRelativePath ?? backupRelativePath, + record.PackageId ?? "(recovered)", + Size: 0, + LastWriteTimeUtc: DateTime.MinValue); + } + else if (string.Equals(record.Action, DeploymentJournalRecord.FileDeployedAction, StringComparison.Ordinal)) + { + sawDeploymentStateRecord = true; + string targetRelativePath = record.TargetRelativePath ?? string.Empty; + backupsByTarget.TryGetValue(targetRelativePath, out string? backupRelativePath); + filesByTarget[targetRelativePath] = new DeploymentFileDocument( + record.SourcePath ?? "(recovered)", + targetRelativePath, + record.Method ?? DeploymentMethod.Copy, + record.BackupRelativePath ?? backupRelativePath, + record.PackageId ?? "(recovered)", + Size: 0, + LastWriteTimeUtc: DateTime.MinValue); + } + else if (string.Equals( + record.Action, + DeploymentJournalRecord.FileCleanupDeleteCompletedAction, + StringComparison.Ordinal)) + { + sawDeploymentStateRecord = true; + filesByTarget.Remove(record.TargetRelativePath); + } + else if (string.Equals( + record.Action, + DeploymentJournalRecord.FileCleanupRestoreStartedAction, + StringComparison.Ordinal)) + { + sawDeploymentStateRecord = true; + if (!string.IsNullOrWhiteSpace(record.BackupRelativePath)) + { + cleanupRestoreStartsByTarget[record.TargetRelativePath] = record.BackupRelativePath; + } + } + else if (string.Equals( + record.Action, + DeploymentJournalRecord.FileCleanupRestoredAction, + StringComparison.Ordinal)) + { + sawDeploymentStateRecord = true; + filesByTarget.Remove(record.TargetRelativePath); + cleanupRestoreStartsByTarget.Remove(record.TargetRelativePath); + } + } + + foreach (KeyValuePair backupStart in backupStartsByTarget) + { + if (filesByTarget.ContainsKey(backupStart.Key) || + !File.Exists(DeploymentPathResolver.ResolveDeploymentStatePath( + paths.DeploymentDirectory, + backupStart.Value))) + { + continue; + } + + filesByTarget[backupStart.Key] = new DeploymentFileDocument( + SourcePath: "(recovered)", + backupStart.Key, + DeploymentMethod.Copy, + backupStart.Value, + PackageId: "(recovered)", + Size: 0, + LastWriteTimeUtc: DateTime.MinValue); + } + + foreach (KeyValuePair restoreStart in cleanupRestoreStartsByTarget) + { + if (!File.Exists(DeploymentPathResolver.ResolveDeploymentStatePath( + paths.DeploymentDirectory, + restoreStart.Value))) + { + filesByTarget.Remove(restoreStart.Key); + } + } + + if (filesByTarget.Count == 0 && directories.Count == 0 && !sawDeploymentStateRecord) + { + return null; + } + + return new DeploymentManifestDocument( + SchemaVersion, + DeploymentId: "recovered", + DateTimeOffset.UtcNow, + filesByTarget.Values.ToList(), + directories.OrderByDescending(path => path.Length).ToList()); + } +} + +/// +/// Describes deployment state paths. +/// +/// The deployment state directory. +/// The active manifest path. +/// The journal path. +/// The operation lock path. +/// The backup directory for the active deployment. +internal sealed record DeploymentStatePaths( + string DeploymentDirectory, + string ActiveManifestPath, + string JournalPath, + string LockPath, + string BackupDirectory); + +/// +/// Describes the persisted deployment manifest document. +/// +/// The schema version. +/// The deployment id. +/// The creation time. +/// The deployed files. +/// The directories created by deployment. +internal sealed record DeploymentManifestDocument( + int SchemaVersion, + string DeploymentId, + DateTimeOffset CreatedAtUtc, + IReadOnlyList Files, + IReadOnlyList CreatedDirectories); + +/// +/// Describes one persisted deployed file entry. +/// +/// The source path. +/// The target relative path. +/// The deployment method. +/// The backup relative path. +/// The package id. +/// The deployed file size. +/// The deployed file last write time. +internal sealed record DeploymentFileDocument( + string SourcePath, + string TargetRelativePath, + DeploymentMethod Method, + string? BackupRelativePath, + string PackageId, + long Size, + DateTime LastWriteTimeUtc); + +/// +/// Describes one append-only journal record. +/// +/// The action name. +/// The target relative path. +/// The backup relative path. +/// The source path. +/// The deployment method. +/// The package id. +internal sealed record DeploymentJournalRecord( + string Action, + string? TargetRelativePath, + string? BackupRelativePath, + string? SourcePath, + DeploymentMethod? Method, + string? PackageId) +{ + /// + /// The directory-created action name. + /// + public const string DirectoryCreatedAction = "directory-created"; + + /// + /// The file-backup-started action name. + /// + public const string FileBackupStartedAction = "file-backup-started"; + + /// + /// The file-backed-up action name. + /// + public const string FileBackedUpAction = "file-backed-up"; + + /// + /// The file-deployment-started action name. + /// + public const string FileDeploymentStartedAction = "file-deployment-started"; + + /// + /// The file-deployed action name. + /// + public const string FileDeployedAction = "file-deployed"; + + /// + /// The file-cleanup-delete-completed action name. + /// + public const string FileCleanupDeleteCompletedAction = "file-cleanup-delete-completed"; + + /// + /// The file-cleanup-restore-started action name. + /// + public const string FileCleanupRestoreStartedAction = "file-cleanup-restore-started"; + + /// + /// The file-cleanup-restored action name. + /// + public const string FileCleanupRestoredAction = "file-cleanup-restored"; + + /// + /// Creates a directory-created journal record. + /// + /// The created directory relative path. + /// The journal record. + public static DeploymentJournalRecord DirectoryCreated(string targetRelativePath) + { + return new DeploymentJournalRecord(DirectoryCreatedAction, targetRelativePath, null, null, null, null); + } + + /// + /// Creates a file-backup-started journal record. + /// + /// The target file relative path. + /// The backup relative path. + /// The journal record. + public static DeploymentJournalRecord FileBackupStarted(string targetRelativePath, string backupRelativePath) + { + return new DeploymentJournalRecord( + FileBackupStartedAction, + targetRelativePath, + backupRelativePath, + null, + null, + null); + } + + /// + /// Creates a file-backed-up journal record. + /// + /// The target file relative path. + /// The backup relative path. + /// The journal record. + public static DeploymentJournalRecord FileBackedUp(string targetRelativePath, string backupRelativePath) + { + return new DeploymentJournalRecord( + FileBackedUpAction, + targetRelativePath, + backupRelativePath, + null, + null, + null); + } + + /// + /// Creates a file-deployment-started journal record. + /// + /// The source path. + /// The target file relative path. + /// The backup relative path. + /// The package id. + /// The journal record. + public static DeploymentJournalRecord FileDeploymentStarted( + string sourcePath, + string targetRelativePath, + string? backupRelativePath, + string packageId) + { + return new DeploymentJournalRecord( + FileDeploymentStartedAction, + targetRelativePath, + backupRelativePath, + sourcePath, + null, + packageId); + } + + /// + /// Creates a file-deployed journal record. + /// + /// The source path. + /// The target file relative path. + /// The deployment method. + /// The backup relative path. + /// The package id. + /// The journal record. + public static DeploymentJournalRecord FileDeployed( + string sourcePath, + string targetRelativePath, + DeploymentMethod method, + string? backupRelativePath, + string packageId) + { + return new DeploymentJournalRecord( + FileDeployedAction, + targetRelativePath, + backupRelativePath, + sourcePath, + method, + packageId); + } + + /// + /// Creates a file-cleanup-delete-completed journal record. + /// + /// The target file relative path. + /// The journal record. + public static DeploymentJournalRecord FileCleanupDeleted(string targetRelativePath) + { + return new DeploymentJournalRecord( + FileCleanupDeleteCompletedAction, + targetRelativePath, + null, + null, + null, + null); + } + + /// + /// Creates a file-cleanup-restore-started journal record. + /// + /// The target file relative path. + /// The backup relative path. + /// The journal record. + public static DeploymentJournalRecord FileCleanupRestoreStarted(string targetRelativePath, string backupRelativePath) + { + return new DeploymentJournalRecord( + FileCleanupRestoreStartedAction, + targetRelativePath, + backupRelativePath, + null, + null, + null); + } + + /// + /// Creates a file-cleanup-restored journal record. + /// + /// The target file relative path. + /// The backup relative path. + /// The journal record. + public static DeploymentJournalRecord FileCleanupRestored(string targetRelativePath, string backupRelativePath) + { + return new DeploymentJournalRecord( + FileCleanupRestoredAction, + targetRelativePath, + backupRelativePath, + null, + null, + null); + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/IHardLinkCreator.cs b/GenLauncherGO.Infrastructure/Launching/Support/IHardLinkCreator.cs new file mode 100644 index 00000000..db3c5506 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/IHardLinkCreator.cs @@ -0,0 +1,15 @@ +namespace GenLauncherGO.Infrastructure.Launching.Support; + +/// +/// Creates hard links between installed package files and game-directory targets. +/// +public interface IHardLinkCreator +{ + /// + /// Attempts to create a hard link. + /// + /// The hard-link path to create. + /// The existing file path to link to. + /// when the hard link was created; otherwise, . + bool TryCreateHardLink(string targetPath, string sourcePath); +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/IProcessFamilyLaunchOperation.cs b/GenLauncherGO.Infrastructure/Launching/Support/IProcessFamilyLaunchOperation.cs new file mode 100644 index 00000000..a0675f4c --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/IProcessFamilyLaunchOperation.cs @@ -0,0 +1,35 @@ +using System; +using System.Threading.Tasks; + +namespace GenLauncherGO.Infrastructure.Launching.Support; + +/// +/// Represents a launched Windows process family that can be observed and force closed. +/// +public interface IProcessFamilyLaunchOperation +{ + /// + /// Gets the executable name used to start the tracked process family. + /// + string ExecutableName { get; } + + /// + /// Gets the executable name for the currently running tracked process. + /// + string CurrentExecutableName { get; } + + /// + /// Occurs when changes. + /// + event EventHandler? CurrentExecutableNameChanged; + + /// + /// Gets the task that completes when every tracked process in the launched process family has exited. + /// + Task Completion { get; } + + /// + /// Force closes all currently tracked running processes in the launched process family. + /// + void ForceClose(); +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/IProcessFamilyLauncher.cs b/GenLauncherGO.Infrastructure/Launching/Support/IProcessFamilyLauncher.cs new file mode 100644 index 00000000..3fc97203 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/IProcessFamilyLauncher.cs @@ -0,0 +1,35 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Infrastructure.Launching.Support; + +/// +/// Starts a process and waits until the launched process family has exited. +/// +public interface IProcessFamilyLauncher +{ + /// + /// Starts the executable and returns an operation that tracks the launched process family. + /// + /// The executable name or path to launch. + /// The command-line arguments to pass to the executable. + /// A token that cancels the process-family wait operation. + /// The tracked process-family launch operation. + Task StartAsync( + string executableName, + string arguments, + CancellationToken cancellationToken); + + /// + /// Starts the executable and waits for the launched process family to exit. + /// + /// The executable name or path to launch. + /// The command-line arguments to pass to the executable. + /// A token that cancels the wait operation. + /// The observed running duration for the launched process family. + Task LaunchAndWaitForExitAsync( + string executableName, + string arguments, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/WindowsHardLinkCreator.cs b/GenLauncherGO.Infrastructure/Launching/Support/WindowsHardLinkCreator.cs new file mode 100644 index 00000000..815a3e0c --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/WindowsHardLinkCreator.cs @@ -0,0 +1,58 @@ +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Launching.Support; + +/// +/// Creates hard links through the Windows file-system API. +/// +public sealed class WindowsHardLinkCreator : IHardLinkCreator +{ + /// + /// The logger used for hard-link creation diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger used for hard-link creation diagnostics. + public WindowsHardLinkCreator(ILogger? logger = null) + { + _logger = logger ?? NullLogger.Instance; + } + + /// + public bool TryCreateHardLink(string targetPath, string sourcePath) + { + bool created = CreateHardLink(targetPath, sourcePath, lpSecurityAttributes: 0); + if (!created) + { + int errorCode = Marshal.GetLastWin32Error(); + _logger.LogWarning( + "Failed to create hard link {TargetFileName} from {SourceFileName}. Win32 error {ErrorCode}: {ErrorMessage}", + Path.GetFileName(targetPath), + Path.GetFileName(sourcePath), + errorCode, + new Win32Exception(errorCode).Message); + } + + return created; + } + + /// + /// Imports the Windows hard-link creation API. + /// + /// The new hard-link path. + /// The existing source file path. + /// Reserved security attributes pointer. + /// when the hard link was created; otherwise, . + [DllImport("kernel32.dll", EntryPoint = "CreateHardLinkW", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern bool CreateHardLink( + string lpFileName, + string lpExistingFileName, + int lpSecurityAttributes); +} diff --git a/GenLauncherGO.Infrastructure/Logging/LoggingServiceCollectionExtensions.cs b/GenLauncherGO.Infrastructure/Logging/LoggingServiceCollectionExtensions.cs new file mode 100644 index 00000000..87db7e18 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Logging/LoggingServiceCollectionExtensions.cs @@ -0,0 +1,116 @@ +using System; +using System.Globalization; +using System.IO; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Serilog; +using Serilog.Events; + +namespace GenLauncherGO.Infrastructure.Logging; + +/// +/// Provides dependency-injection registration helpers for GenLauncherGO logging infrastructure. +/// +public static class LoggingServiceCollectionExtensions +{ + /// + /// The number of session log files retained for desktop diagnostics. + /// + private const int RetainedLogFileCount = 14; + + /// + /// The readable prefix used for GenLauncherGO log files. + /// + private const string LogFilePrefix = "GenLauncherGO"; + + /// + /// Registers the standard GenLauncherGO logging pipeline with rolling file logs. + /// + /// The service collection used by the application composition root. + /// The directory where rolling log files should be written. + /// The same service collection so additional registrations can be chained. + /// + /// Thrown when is . + /// + /// + /// Thrown when is , empty, or whitespace. + /// + public static IServiceCollection AddGenLauncherGoLogging( + this IServiceCollection services, + string logDirectory) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(logDirectory); + + Directory.CreateDirectory(logDirectory); + + string logFilePath = CreateLogFilePath(logDirectory); + PruneOldLogFiles(logDirectory, logFilePath); + Serilog.ILogger logger = new LoggerConfiguration() + .MinimumLevel.Information() + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .Enrich.FromLogContext() + .WriteTo.File( + new SensitiveDataRedactingTextFormatter(), + logFilePath, + shared: false) + .CreateLogger(); + + services.AddLogging(builder => + { + builder.ClearProviders(); + builder.AddSerilog(logger, dispose: true); + }); + + return services; + } + + /// + /// Creates the readable UTC session log file path for the current launcher session. + /// + /// The directory where the session log file will be written. + /// The full session log file path. + private static string CreateLogFilePath(string logDirectory) + { + string timestamp = DateTimeOffset.UtcNow.ToString("yyyy-MM-dd-HHmmss'Z'", CultureInfo.InvariantCulture); + string baseLogFileName = $"{LogFilePrefix}-{timestamp}"; + string logFilePath = Path.Combine(logDirectory, baseLogFileName + ".log"); + int collisionIndex = 2; + while (File.Exists(logFilePath)) + { + logFilePath = Path.Combine(logDirectory, $"{baseLogFileName}-{collisionIndex}.log"); + collisionIndex++; + } + + return logFilePath; + } + + /// + /// Deletes older GenLauncherGO logs before the active log is opened. + /// + /// The directory containing log files. + /// The log file reserved for the current session. + private static void PruneOldLogFiles(string logDirectory, string activeLogFilePath) + { + FileInfo[] logFiles = new DirectoryInfo(logDirectory).GetFiles($"{LogFilePrefix}-*.log"); + Array.Sort(logFiles, (left, right) => right.LastWriteTimeUtc.CompareTo(left.LastWriteTimeUtc)); + + string activePath = Path.GetFullPath(activeLogFilePath); + int retainedCount = 1; + foreach (FileInfo logFile in logFiles) + { + if (string.Equals(Path.GetFullPath(logFile.FullName), activePath, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (retainedCount < RetainedLogFileCount) + { + retainedCount++; + continue; + } + + logFile.Delete(); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Logging/SensitiveDataRedactingTextFormatter.cs b/GenLauncherGO.Infrastructure/Logging/SensitiveDataRedactingTextFormatter.cs new file mode 100644 index 00000000..11794377 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Logging/SensitiveDataRedactingTextFormatter.cs @@ -0,0 +1,100 @@ +using System; +using System.Globalization; +using System.IO; +using System.Text.RegularExpressions; +using Serilog.Events; +using Serilog.Formatting; + +namespace GenLauncherGO.Infrastructure.Logging; + +/// +/// Formats log events while removing local paths and obvious secret-bearing URL values. +/// +internal sealed class SensitiveDataRedactingTextFormatter : ITextFormatter +{ + /// + /// Replaces source-file paths emitted by exception stack traces. + /// + private static readonly Regex _stackTraceSourcePathPattern = new( + @"\sin\s[A-Za-z]:\\[^\r\n]*:line\s(?\d+)", + RegexOptions.Compiled); + + /// + /// Replaces local absolute Windows paths rendered in event messages or exception text. + /// + private static readonly Regex _absoluteWindowsPathPattern = new( + @"\b[A-Za-z]:\\(?:[^\\\r\n:*?""<>|]+\\)*[^\\\r\n:*?""<>|]*", + RegexOptions.Compiled); + + /// + /// Replaces URI user-info credentials. + /// + private static readonly Regex _uriUserInfoPattern = new( + @"(?i)(?\b[a-z][a-z0-9+.-]*://)[^/\s?#@]+@", + RegexOptions.Compiled); + + /// + /// Replaces common token, key, credential, secret, signature, and password query-string values. + /// + private static readonly Regex _sensitiveQueryValuePattern = new( + @"(?i)(?[?&](?:access[_-]?token|api[_-]?key|credential|secret|token|session[_-]?token|" + + @"security[_-]?token|password|signature|sig|x-amz-(?:credential|signature|security-token))=)[^&\s]+", + RegexOptions.Compiled); + + /// + /// Initializes a new instance of the class. + /// + public SensitiveDataRedactingTextFormatter() + { + } + + /// + public void Format(LogEvent logEvent, TextWriter output) + { + ArgumentNullException.ThrowIfNull(logEvent); + ArgumentNullException.ThrowIfNull(output); + + output.Write(logEvent.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff zzz", CultureInfo.InvariantCulture)); + output.Write(" ["); + output.Write(GetLevelAbbreviation(logEvent.Level)); + output.Write("] "); + output.WriteLine(Redact(logEvent.RenderMessage(CultureInfo.InvariantCulture))); + + if (logEvent.Exception != null) + { + output.WriteLine(Redact(logEvent.Exception.ToString())); + } + } + + /// + /// Gets the three-letter Serilog level abbreviation used by the existing file log format. + /// + /// The Serilog event level. + /// The level abbreviation. + private static string GetLevelAbbreviation(LogEventLevel level) + { + return level switch + { + LogEventLevel.Verbose => "VRB", + LogEventLevel.Debug => "DBG", + LogEventLevel.Information => "INF", + LogEventLevel.Warning => "WRN", + LogEventLevel.Error => "ERR", + LogEventLevel.Fatal => "FTL", + _ => level.ToString().ToUpperInvariant(), + }; + } + + /// + /// Removes sensitive path and query-string content from rendered log text. + /// + /// The rendered log text. + /// The redacted text. + private static string Redact(string value) + { + string redacted = _stackTraceSourcePathPattern.Replace(value, " in [local source]:line ${line}"); + redacted = _uriUserInfoPattern.Replace(redacted, "${scheme}[redacted]@"); + redacted = _sensitiveQueryValuePattern.Replace(redacted, "${key}[redacted]"); + return _absoluteWindowsPathPattern.Replace(redacted, "[local path]"); + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Composition/ModsInfrastructureServiceCollectionExtensions.cs b/GenLauncherGO.Infrastructure/Mods/Composition/ModsInfrastructureServiceCollectionExtensions.cs new file mode 100644 index 00000000..8bc8990d --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Composition/ModsInfrastructureServiceCollectionExtensions.cs @@ -0,0 +1,72 @@ +using System; +using GenLauncherGO.Core.Archives; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Remote; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Infrastructure.Archives; +using GenLauncherGO.Infrastructure.Mods.Contracts; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Infrastructure.Persistence.Options; +using GenLauncherGO.Infrastructure.Persistence.Services; +using GenLauncherGO.Infrastructure.Remote; +using GenLauncherGO.Infrastructure.Updating.Clients; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Mods.Composition; + +/// +/// Provides dependency-injection registrations for launcher content infrastructure. +/// +public static class ModsInfrastructureServiceCollectionExtensions +{ + /// + /// Registers infrastructure services used by launcher content workflows. + /// + /// The service collection used by the application composition root. + /// The YAML file path where launcher content state is persisted. + /// The same service collection so additional registrations can be chained. + /// + /// Thrown when is . + /// + /// + /// Thrown when is , empty, or whitespace. + /// + public static IServiceCollection AddGenLauncherGoModsInfrastructure( + this IServiceCollection services, + string launcherDataFilePath) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(launcherDataFilePath); + + services.AddSingleton>(serviceProvider => + new YamlDocumentStore( + new YamlDocumentStoreOptions(launcherDataFilePath), + serviceProvider.GetRequiredService>>())); + services.TryAddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.TryAddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService()); + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService()); + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService()); + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService()); + return services; + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherCatalogImageCache.cs b/GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherCatalogImageCache.cs new file mode 100644 index 00000000..104e14e4 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherCatalogImageCache.cs @@ -0,0 +1,36 @@ +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Contracts; + +/// +/// Caches remote launcher catalog images in the local launcher image folder. +/// +internal interface ILauncherCatalogImageCache +{ + /// + /// Downloads missing card and background images for a remote modification. + /// + /// The normalized remote modification metadata. + /// The launcher paths used to resolve cache destinations. + /// The token used to cancel remote work. + /// A task that completes when the image cache has been updated. + Task CacheModificationImagesAsync( + RemoteContentManifest modification, + LauncherPaths paths, + CancellationToken cancellationToken); + + /// + /// Downloads missing advertising images and removes stale advertising image variants. + /// + /// The normalized advertising image metadata. + /// The launcher paths used to resolve cache destinations. + /// The token used to cancel remote work. + /// A task that completes when the advertising image cache has been updated. + Task CacheAdvertisingImagesAsync( + RemoteAdvertisingReference advertising, + LauncherPaths paths, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherContentStateMapper.cs b/GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherContentStateMapper.cs new file mode 100644 index 00000000..7c919b4c --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherContentStateMapper.cs @@ -0,0 +1,47 @@ +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Contracts; + +/// +/// Maps persisted compact content state to the mutable launcher catalog facade models. +/// +internal interface ILauncherContentStateMapper +{ + /// + /// Converts persisted compact state into the in-memory catalog. + /// + /// The persisted content state. + /// The in-memory catalog. + LauncherData ToLauncherData(LauncherContentState state); + + /// + /// Converts the in-memory catalog into persisted compact state. + /// + /// The in-memory catalog. + /// The persisted compact state. + LauncherContentState ToLauncherContentState(LauncherData launcherData); + + /// + /// Converts one installed content state into a catalog version. + /// + /// The installed content state. + /// The catalog version. + ModificationVersion ToModificationVersion(LauncherContentVersionState version); + + /// + /// Converts a catalog version into persisted compact state. + /// + /// The catalog version. + /// The content type to use when catalog state is ambiguous. + /// The persisted version state. + LauncherContentVersionState ToVersionState( + ModificationVersion version, + LauncherContentType fallbackType); + + /// + /// Gets the fallback compact content type for a legacy modification type. + /// + /// The legacy modification type. + /// The fallback compact content type. + LauncherContentType GetFallbackContentType(ModificationType type); +} diff --git a/GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherLocalContentReconciler.cs b/GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherLocalContentReconciler.cs new file mode 100644 index 00000000..80f3ac9e --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherLocalContentReconciler.cs @@ -0,0 +1,50 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Infrastructure.Mods.Contracts; + +/// +/// Reconciles mutable launcher catalog state with locally installed content folders. +/// +internal interface ILauncherLocalContentReconciler +{ + /// + /// Adds locally installed versions that are missing from the current catalog and removes stale local-only records. + /// + /// The mutable launcher catalog. + /// Remote content versions already downloaded into the catalog. + /// The launcher paths used to inspect local content folders. + /// The local content folder layout. + void Reconcile( + LauncherData launcherData, + IReadOnlyCollection downloadedReposContent, + LauncherPaths paths, + LauncherContentLayout layout); + + /// + /// Deletes a content version from local storage or from the advertising-only catalog list. + /// + /// The mutable launcher catalog. + /// The content version to delete. + /// The launcher paths used to locate local content folders. + /// The local content folder layout. + void DeleteVersion( + LauncherData launcherData, + ModificationVersion modificationVersion, + LauncherPaths paths, + LauncherContentLayout layout); + + /// + /// Deletes all local files for a content card while leaving catalog removal to the caller. + /// + /// The mutable launcher catalog. + /// A version that identifies the content card to delete. + /// The launcher paths. + /// The content folder layout. + void DeleteContent( + LauncherData launcherData, + ModificationVersion modificationVersion, + LauncherPaths paths, + LauncherContentLayout layout); +} diff --git a/GenLauncherGO.Infrastructure/Mods/Contracts/IRemoteLauncherCatalogClient.cs b/GenLauncherGO.Infrastructure/Mods/Contracts/IRemoteLauncherCatalogClient.cs new file mode 100644 index 00000000..69e2a1d0 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Contracts/IRemoteLauncherCatalogClient.cs @@ -0,0 +1,72 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Contracts; + +/// +/// Reads remote launcher catalog manifests through a compatibility mapping boundary. +/// +internal interface IRemoteLauncherCatalogClient +{ + /// + /// Reads the top-level repository catalog. + /// + /// The top-level repository manifest URI. + /// The token used to cancel remote work. + /// The normalized repository catalog, or an empty catalog when the remote document is empty. + Task ReadCatalogAsync(Uri manifestUri, CancellationToken cancellationToken); + + /// + /// Gets advertised modification names from a repository catalog. + /// + /// The normalized repository catalog. + /// The advertised modification names. + IReadOnlyList GetModificationNames(RemoteLauncherCatalog catalog); + + /// + /// Downloads remote manifest data for locally installed modifications. + /// + /// The normalized repository catalog. + /// The installed modification names. + /// The token used to cancel remote work. + /// Downloaded modification manifests with child-content link data. + Task> DownloadInstalledModDataAsync( + RemoteLauncherCatalog catalog, + IReadOnlyCollection installedModNames, + CancellationToken cancellationToken); + + /// + /// Downloads one remote modification manifest by content name. + /// + /// The normalized repository catalog. + /// The modification name. + /// The token used to cancel remote work. + /// The downloaded modification manifest and child-content link data. + Task DownloadModDataByNameAsync( + RemoteLauncherCatalog catalog, + string name, + CancellationToken cancellationToken); + + /// + /// Reads add-on or patch manifests, preserving partial success when one child manifest fails. + /// + /// The child manifest URLs. + /// The token used to cancel remote work. + /// The successfully read child manifests. + Task> ReadChildManifestsAsync( + IEnumerable manifestUrls, + CancellationToken cancellationToken); + + /// + /// Downloads advertising modification metadata. + /// + /// The advertising manifest URL. + /// The token used to cancel remote work. + /// The advertising manifest, or when it cannot be read. + Task DownloadAdvertisingInfoAsync( + string manifestUrl, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/RemoteAdvertisingReference.cs b/GenLauncherGO.Infrastructure/Mods/Models/RemoteAdvertisingReference.cs new file mode 100644 index 00000000..40681527 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/RemoteAdvertisingReference.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Represents a normalized remote advertising manifest reference. +/// +internal sealed class RemoteAdvertisingReference +{ + /// + /// Initializes a new instance of the class. + /// + /// The advertised content name. + /// The advertised content manifest URL. + /// The advertising image URLs. + public RemoteAdvertisingReference( + string name, + string manifestUrl, + IReadOnlyList imageUrls) + { + Name = name ?? string.Empty; + ManifestUrl = manifestUrl ?? string.Empty; + ImageUrls = imageUrls ?? Array.Empty(); + } + + /// + /// Gets the advertised content name. + /// + public string Name { get; } + + /// + /// Gets the advertised content manifest URL. + /// + public string ManifestUrl { get; } + + /// + /// Gets advertising image URLs. + /// + public IReadOnlyList ImageUrls { get; } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/RemoteCatalogModificationReference.cs b/GenLauncherGO.Infrastructure/Mods/Models/RemoteCatalogModificationReference.cs new file mode 100644 index 00000000..9494ab34 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/RemoteCatalogModificationReference.cs @@ -0,0 +1,49 @@ +using System; +using System.Collections.Generic; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Represents a normalized remote modification manifest reference. +/// +internal sealed class RemoteCatalogModificationReference +{ + /// + /// Initializes a new instance of the class. + /// + /// The modification name. + /// The modification manifest URL. + /// The patch manifest URLs associated with the modification. + /// The add-on manifest URLs associated with the modification. + public RemoteCatalogModificationReference( + string name, + string manifestUrl, + IReadOnlyList patchManifestUrls, + IReadOnlyList addonManifestUrls) + { + Name = name ?? string.Empty; + ManifestUrl = manifestUrl ?? string.Empty; + PatchManifestUrls = patchManifestUrls ?? Array.Empty(); + AddonManifestUrls = addonManifestUrls ?? Array.Empty(); + } + + /// + /// Gets the modification name. + /// + public string Name { get; } + + /// + /// Gets the modification manifest URL. + /// + public string ManifestUrl { get; } + + /// + /// Gets patch manifest URLs associated with the modification. + /// + public IReadOnlyList PatchManifestUrls { get; } + + /// + /// Gets add-on manifest URLs associated with the modification. + /// + public IReadOnlyList AddonManifestUrls { get; } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/RemoteContentManifest.cs b/GenLauncherGO.Infrastructure/Mods/Models/RemoteContentManifest.cs new file mode 100644 index 00000000..7782c343 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/RemoteContentManifest.cs @@ -0,0 +1,105 @@ +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Represents normalized metadata from one remote content manifest. +/// +internal sealed class RemoteContentManifest +{ + /// + /// Gets or sets the content category. + /// + public ModificationType ModificationType { get; set; } + + /// + /// Gets or sets the content name. + /// + public string Name { get; set; } = string.Empty; + + /// + /// Gets or sets the content version label. + /// + public string Version { get; set; } = string.Empty; + + /// + /// Gets or sets the direct package download link. + /// + public string SimpleDownloadLink { get; set; } = string.Empty; + + /// + /// Gets or sets the card image source link. + /// + public string ImageSourceLink { get; set; } = string.Empty; + + /// + /// Gets or sets the Discord link. + /// + public string DiscordLink { get; set; } = string.Empty; + + /// + /// Gets or sets the ModDB link. + /// + public string ModDbLink { get; set; } = string.Empty; + + /// + /// Gets or sets the news link. + /// + public string NewsLink { get; set; } = string.Empty; + + /// + /// Gets or sets the parent content name for add-ons and patches. + /// + public string DependenceName { get; set; } = string.Empty; + + /// + /// Gets or sets the S3 endpoint URL. + /// + public string S3HostLink { get; set; } = string.Empty; + + /// + /// Gets or sets the S3 bucket name. + /// + public string S3BucketName { get; set; } = string.Empty; + + /// + /// Gets or sets the S3 folder name. + /// + public string S3FolderName { get; set; } = string.Empty; + + /// + /// Gets or sets the S3 public key. + /// + public string S3HostPublicKey { get; set; } = string.Empty; + + /// + /// Gets or sets the S3 secret key. + /// + public string S3HostSecretKey { get; set; } = string.Empty; + + /// + /// Gets or sets network information text. + /// + public string NetworkInfo { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating whether the content is deprecated. + /// + public bool Deprecated { get; set; } + + /// + /// Gets or sets the support link. + /// + public string SupportLink { get; set; } = string.Empty; + + /// + /// Gets or sets optional launcher color information. + /// + public ColorsInfoString? ColorsInformation { get; set; } + + /// + /// Gets or sets the content source kind used by launch integrity checks. + /// + public ContentSourceKind ContentSourceKind { get; set; } = ContentSourceKind.UnknownLegacy; +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/RemoteLauncherCatalog.cs b/GenLauncherGO.Infrastructure/Mods/Models/RemoteLauncherCatalog.cs new file mode 100644 index 00000000..98dcdd96 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/RemoteLauncherCatalog.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Represents a normalized remote launcher catalog after third-party backend YAML has been mapped. +/// +internal sealed class RemoteLauncherCatalog +{ + /// + /// Initializes a new instance of the class. + /// + /// The advertising manifest references. + /// The modification manifest references. + /// The original-game add-on manifest URLs. + /// The original-game patch manifest URLs. + /// The launcher version advertised by the remote catalog. + public RemoteLauncherCatalog( + IReadOnlyList advertisingEntries, + IReadOnlyList modifications, + IReadOnlyList originalGameAddonManifestUrls, + IReadOnlyList originalGamePatchManifestUrls, + string launcherVersion) + { + AdvertisingEntries = advertisingEntries ?? Array.Empty(); + Modifications = modifications ?? Array.Empty(); + OriginalGameAddonManifestUrls = originalGameAddonManifestUrls ?? Array.Empty(); + OriginalGamePatchManifestUrls = originalGamePatchManifestUrls ?? Array.Empty(); + LauncherVersion = launcherVersion ?? string.Empty; + } + + /// + /// Gets an empty remote catalog. + /// + public static RemoteLauncherCatalog Empty { get; } = new( + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + string.Empty); + + /// + /// Gets advertising manifest references. + /// + public IReadOnlyList AdvertisingEntries { get; } + + /// + /// Gets modification manifest references. + /// + public IReadOnlyList Modifications { get; } + + /// + /// Gets original-game add-on manifest URLs. + /// + public IReadOnlyList OriginalGameAddonManifestUrls { get; } + + /// + /// Gets original-game patch manifest URLs. + /// + public IReadOnlyList OriginalGamePatchManifestUrls { get; } + + /// + /// Gets the launcher version advertised by the remote catalog. + /// + public string LauncherVersion { get; } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/RemoteModificationManifest.cs b/GenLauncherGO.Infrastructure/Mods/Models/RemoteModificationManifest.cs new file mode 100644 index 00000000..36865fbe --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/RemoteModificationManifest.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Represents a normalized remote modification manifest with its child manifest references. +/// +internal sealed class RemoteModificationManifest +{ + /// + /// Initializes a new instance of the class. + /// + /// The normalized content manifest. + /// The patch manifest URLs associated with the content. + /// The add-on manifest URLs associated with the content. + public RemoteModificationManifest( + RemoteContentManifest content, + IReadOnlyList patchManifestUrls, + IReadOnlyList addonManifestUrls) + { + Content = content ?? throw new ArgumentNullException(nameof(content)); + PatchManifestUrls = patchManifestUrls ?? Array.Empty(); + AddonManifestUrls = addonManifestUrls ?? Array.Empty(); + } + + /// + /// Gets the normalized content manifest. + /// + public RemoteContentManifest Content { get; } + + /// + /// Gets patch manifest URLs associated with the content. + /// + public IReadOnlyList PatchManifestUrls { get; } + + /// + /// Gets add-on manifest URLs associated with the content. + /// + public IReadOnlyList AddonManifestUrls { get; } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/FileSystemLocalLauncherContentService.cs b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemLocalLauncherContentService.cs new file mode 100644 index 00000000..aaa83ab5 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemLocalLauncherContentService.cs @@ -0,0 +1,657 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Performs local file-system operations for launcher-managed mods, patches, add-ons, and cached images. +/// +public sealed class FileSystemLocalLauncherContentService : ILocalLauncherContentService +{ + /// + /// The logger used for local content file-system diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger used for local content file-system diagnostics. + public FileSystemLocalLauncherContentService(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public IReadOnlyList FindInstalledVersions( + LauncherPaths paths, + LauncherContentLayout layout) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(layout); + + var versions = new List(); + var modsDirectory = new DirectoryInfo(paths.ModsDirectory); + if (!modsDirectory.Exists) + { + return versions; + } + + foreach (DirectoryInfo contentDirectory in modsDirectory.GetDirectories()) + { + AddInstalledVersions(contentDirectory, layout, versions); + } + + return versions; + } + + /// + public bool VersionFolderContainsFiles( + LauncherPaths paths, + LauncherContentLayout layout, + LauncherContentVersionState version) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(version); + + string? versionDirectoryPath = GetVersionDirectoryPath(paths, layout, version); + return !string.IsNullOrWhiteSpace(versionDirectoryPath) && + Directory.Exists(versionDirectoryPath) && + ModFolderContainsFiles(new DirectoryInfo(versionDirectoryPath)); + } + + /// + public bool VersionFolderExists( + LauncherPaths paths, + LauncherContentLayout layout, + LauncherContentVersionState version) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(version); + + string? versionDirectoryPath = GetVersionDirectoryPath(paths, layout, version); + return !string.IsNullOrWhiteSpace(versionDirectoryPath) && + Directory.Exists(versionDirectoryPath); + } + + /// + public void DeleteVersion( + LauncherPaths paths, + LauncherContentLayout layout, + LauncherContentVersionState version) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(version); + + if (version.ModificationType == LauncherContentType.Advertising) + { + return; + } + + string? versionDirectoryPath = GetVersionDirectoryPath(paths, layout, version); + if (string.IsNullOrWhiteSpace(versionDirectoryPath)) + { + return; + } + + EnsurePathBelowModsRoot(paths, versionDirectoryPath); + bool deletedInstalledVersion = DeleteDirectoryIfExists( + versionDirectoryPath, + "Deleted launcher content version {ContentName} {ContentVersion}.", + version.Name, + version.Version); + + DeletePackageStagingDirectory(paths, versionDirectoryPath, version); + + if (deletedInstalledVersion) + { + string? cleanupRoot = GetCleanupRootDirectoryPath(paths, version); + if (!string.IsNullOrWhiteSpace(cleanupRoot)) + { + DeleteEmptyDirectoryTree(paths, cleanupRoot); + } + } + } + + /// + public void DeleteContent( + LauncherPaths paths, + LauncherContentLayout layout, + LauncherContentVersionState version) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(layout); + ArgumentNullException.ThrowIfNull(version); + + if (version.ModificationType == LauncherContentType.Advertising) + { + return; + } + + string? contentDirectoryPath = GetContentDirectoryPath(paths, layout, version); + if (string.IsNullOrWhiteSpace(contentDirectoryPath)) + { + return; + } + + EnsurePathBelowModsRoot(paths, contentDirectoryPath); + bool deletedContent = DeleteDirectoryIfExists( + contentDirectoryPath, + "Deleted launcher content {ContentName} {ContentVersion}.", + version.Name, + version.Version); + + DeletePackageStagingDirectory(paths, contentDirectoryPath, version); + + if (deletedContent) + { + string? cleanupRoot = GetCleanupRootDirectoryPath(paths, version); + if (!string.IsNullOrWhiteSpace(cleanupRoot)) + { + DeleteEmptyDirectoryTree(paths, cleanupRoot); + } + } + } + + /// + public void DeleteImagesIfUnused( + LauncherPaths paths, + LauncherContentVersionState version, + LauncherContentState currentState) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(version); + ArgumentNullException.ThrowIfNull(currentState); + + if (version.ModificationType == LauncherContentType.Advertising || + string.IsNullOrWhiteSpace(version.Name) || + string.IsNullOrWhiteSpace(version.Version) || + ContentCardExists(currentState, version.Name)) + { + return; + } + + string imageFolderPath = paths.GetModificationImagesDirectory(version.Name); + if (!Directory.Exists(imageFolderPath)) + { + return; + } + + DeleteImageFiles(imageFolderPath, version.Version); + DeleteImageFiles(imageFolderPath, version.Version + "-background"); + DeleteImageFolderIfEmpty(imageFolderPath); + } + + /// + /// Adds installed versions discovered below one top-level content directory. + /// + /// The top-level content directory. + /// The content folder layout. + /// The target version list. + private static void AddInstalledVersions( + DirectoryInfo contentDirectory, + LauncherContentLayout layout, + List versions) + { + foreach (DirectoryInfo subDirectory in contentDirectory.GetDirectories()) + { + if (String.Equals(subDirectory.Name, layout.AddonsFolderName, StringComparison.OrdinalIgnoreCase)) + { + foreach (DirectoryInfo addonDirectory in subDirectory.GetDirectories()) + { + AddInstalledChildVersions( + addonDirectory, + contentDirectory.Name, + LauncherContentType.Addon, + versions); + } + + continue; + } + + if (String.Equals(subDirectory.Name, layout.PatchesFolderName, StringComparison.OrdinalIgnoreCase)) + { + foreach (DirectoryInfo patchDirectory in subDirectory.GetDirectories()) + { + AddInstalledChildVersions( + patchDirectory, + contentDirectory.Name, + LauncherContentType.Patch, + versions); + } + + continue; + } + + if (IsInstallVersionDirectory(subDirectory)) + { + versions.Add(new LauncherContentVersionState + { + ModificationType = LauncherContentType.Mod, + Name = contentDirectory.Name, + Version = subDirectory.Name, + Installed = true + }); + } + } + } + + /// + /// Adds installed add-on or patch versions discovered below a child content directory. + /// + /// The child content directory. + /// The parent modification name. + /// The child content type. + /// The target version list. + private static void AddInstalledChildVersions( + DirectoryInfo contentDirectory, + string dependenceName, + LauncherContentType contentType, + List versions) + { + foreach (DirectoryInfo versionDirectory in contentDirectory.GetDirectories()) + { + if (!IsInstallVersionDirectory(versionDirectory)) + { + continue; + } + + versions.Add(new LauncherContentVersionState + { + ModificationType = contentType, + Name = contentDirectory.Name, + Version = versionDirectory.Name, + DependenceName = dependenceName, + Installed = true + }); + } + } + + /// + /// Determines whether a directory represents an installed version folder. + /// + /// The candidate directory. + /// when the directory is an installed version folder. + private static bool IsInstallVersionDirectory(DirectoryInfo directory) + { + return ModFolderContainsFiles(directory); + } + + /// + /// Builds the installed version directory path for a content version. + /// + /// The resolved launcher paths. + /// The content folder layout. + /// The content version. + /// The installed version directory path, or when it cannot be built. + private static string? GetVersionDirectoryPath( + LauncherPaths paths, + LauncherContentLayout layout, + LauncherContentVersionState version) + { + if (string.IsNullOrWhiteSpace(version.Name) || string.IsNullOrWhiteSpace(version.Version)) + { + return null; + } + + return version.ModificationType switch + { + LauncherContentType.Addon when !string.IsNullOrWhiteSpace(version.DependenceName) => Path.Combine( + paths.ModsDirectory, + version.DependenceName, + layout.AddonsFolderName, + version.Name, + version.Version), + LauncherContentType.Patch when !string.IsNullOrWhiteSpace(version.DependenceName) => Path.Combine( + paths.ModsDirectory, + version.DependenceName, + layout.PatchesFolderName, + version.Name, + version.Version), + LauncherContentType.Mod => Path.Combine(paths.ModsDirectory, version.Name, version.Version), + _ => null + }; + } + + /// + /// Builds the installed content card directory path for a content version. + /// + /// The resolved launcher paths. + /// The content folder layout. + /// The content version. + /// The installed content directory path, or when it cannot be built. + private static string? GetContentDirectoryPath( + LauncherPaths paths, + LauncherContentLayout layout, + LauncherContentVersionState version) + { + if (string.IsNullOrWhiteSpace(version.Name)) + { + return null; + } + + return version.ModificationType switch + { + LauncherContentType.Addon when !string.IsNullOrWhiteSpace(version.DependenceName) => Path.Combine( + paths.ModsDirectory, + version.DependenceName, + layout.AddonsFolderName, + version.Name), + LauncherContentType.Patch when !string.IsNullOrWhiteSpace(version.DependenceName) => Path.Combine( + paths.ModsDirectory, + version.DependenceName, + layout.PatchesFolderName, + version.Name), + LauncherContentType.Mod => Path.Combine(paths.ModsDirectory, version.Name), + _ => null + }; + } + + /// + /// Builds the root directory that can be pruned after a version is deleted. + /// + /// The resolved launcher paths. + /// The deleted content version. + /// The cleanup root directory path, or when it cannot be built. + private static string? GetCleanupRootDirectoryPath( + LauncherPaths paths, + LauncherContentVersionState version) + { + if (string.IsNullOrWhiteSpace(version.Name)) + { + return null; + } + + return version.ModificationType switch + { + LauncherContentType.Addon when !string.IsNullOrWhiteSpace(version.DependenceName) => + Path.Combine(paths.ModsDirectory, version.DependenceName), + LauncherContentType.Patch when !string.IsNullOrWhiteSpace(version.DependenceName) => + Path.Combine(paths.ModsDirectory, version.DependenceName), + LauncherContentType.Mod => Path.Combine(paths.ModsDirectory, version.Name), + _ => null + }; + } + + /// + /// Deletes an empty directory tree without crossing outside the launcher-owned mods root. + /// + /// The resolved launcher paths. + /// The directory path to prune. + private static void DeleteEmptyDirectoryTree(LauncherPaths paths, string directoryPath) + { + if (string.IsNullOrWhiteSpace(directoryPath) || + !Directory.Exists(directoryPath) || + !IsPathBelowModsRoot(paths, directoryPath) || + IsReparsePoint(directoryPath)) + { + return; + } + + foreach (string childDirectoryPath in Directory.EnumerateDirectories(directoryPath).ToList()) + { + DeleteEmptyDirectoryTree(paths, childDirectoryPath); + } + + if (!Directory.EnumerateFileSystemEntries(directoryPath).Any()) + { + Directory.Delete(directoryPath); + } + } + + /// + /// Determines whether a path is below the launcher-owned mods root. + /// + /// The resolved launcher paths. + /// The candidate directory path. + /// when the path is below the mods root. + private static bool IsPathBelowModsRoot(LauncherPaths paths, string directoryPath) + { + string modsRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(paths.ModsDirectory)); + string fullPath = Path.GetFullPath(directoryPath); + return !String.Equals(fullPath, modsRoot, StringComparison.OrdinalIgnoreCase) && + fullPath.StartsWith( + modsRoot + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase); + } + + /// + /// Verifies that a path stays below the launcher-owned mods root. + /// + /// The resolved launcher paths. + /// The candidate directory path. + /// Thrown when the candidate path is outside the mods root. + private static void EnsurePathBelowModsRoot(LauncherPaths paths, string directoryPath) + { + if (!IsPathBelowModsRoot(paths, directoryPath)) + { + throw new InvalidOperationException("Refusing to delete a launcher content path outside the mods root."); + } + } + + /// + /// Deletes the temporary package staging directory for a content version when it exists. + /// + /// The resolved launcher paths. + /// The installed version directory path. + /// The content version being deleted. + private void DeletePackageStagingDirectory( + LauncherPaths paths, + string versionDirectoryPath, + LauncherContentVersionState version) + { + string packageStagingDirectory = paths.GetPackageTemporaryFolderPath(versionDirectoryPath); + string packagesRoot = Path.Combine(paths.TempDirectory, "Packages"); + if (!IsPathBelowRoot(packagesRoot, packageStagingDirectory)) + { + throw new InvalidOperationException("Refusing to delete a package staging path outside the temp root."); + } + + DeleteDirectoryIfExists( + packageStagingDirectory, + "Deleted temporary launcher package staging folder for {ContentName} {ContentVersion}.", + version.Name, + version.Version); + DeleteEmptyPackageStagingParents(packagesRoot, packageStagingDirectory); + } + + /// + /// Deletes empty package staging parent directories without crossing outside the package staging root. + /// + /// The package staging root directory. + /// The package staging directory that was deleted. + private void DeleteEmptyPackageStagingParents(string packagesRoot, string packageStagingDirectory) + { + DirectoryInfo? currentDirectory = Directory.GetParent(Path.GetFullPath(packageStagingDirectory)); + while (currentDirectory is not null && + IsPathBelowRoot(packagesRoot, currentDirectory.FullName) && + Directory.Exists(currentDirectory.FullName) && + !IsReparsePoint(currentDirectory.FullName) && + !Directory.EnumerateFileSystemEntries(currentDirectory.FullName).Any()) + { + DirectoryInfo? parentDirectory = currentDirectory.Parent; + Directory.Delete(currentDirectory.FullName); + _logger.LogInformation( + "Deleted empty temporary launcher package staging folder {StagingFolderName}.", + currentDirectory.Name); + currentDirectory = parentDirectory; + } + } + + /// + /// Deletes a directory when it exists. + /// + /// The directory to delete. + /// The structured log message. + /// The content name for diagnostics. + /// The content version for diagnostics. + /// when a directory was deleted. + private bool DeleteDirectoryIfExists( + string directoryPath, + string logMessage, + string contentName, + string contentVersion) + { + if (!Directory.Exists(directoryPath)) + { + return false; + } + + Directory.Delete(directoryPath, recursive: true); + _logger.LogInformation(logMessage, contentName, contentVersion); + return true; + } + + /// + /// Determines whether a path is below a specific root directory. + /// + /// The root directory path. + /// The candidate directory path. + /// when the path is below the root. + private static bool IsPathBelowRoot(string rootDirectoryPath, string directoryPath) + { + string root = Path.TrimEndingDirectorySeparator(Path.GetFullPath(rootDirectoryPath)); + string fullPath = Path.GetFullPath(directoryPath); + return !String.Equals(fullPath, root, StringComparison.OrdinalIgnoreCase) && + fullPath.StartsWith( + root + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase); + } + + /// + /// Determines whether a directory is a reparse point. + /// + /// The directory path to inspect. + /// when the directory is a reparse point. + private static bool IsReparsePoint(string directoryPath) + { + return (File.GetAttributes(directoryPath) & FileAttributes.ReparsePoint) != 0; + } + + /// + /// Determines whether a mod folder contains files directly or below one child folder. + /// + /// The directory to inspect. + /// when the folder contains files. + private static bool ModFolderContainsFiles(DirectoryInfo directoryInfo) + { + foreach (FileInfo file in directoryInfo.GetFiles()) + { + return true; + } + + foreach (DirectoryInfo folder in directoryInfo.GetDirectories()) + { + if (FolderContainsFiles(folder)) + { + return true; + } + } + + return false; + } + + /// + /// Determines whether a folder contains files, following the legacy first-child traversal behavior. + /// + /// The directory to inspect. + /// when the folder contains files. + private static bool FolderContainsFiles(DirectoryInfo directoryInfo) + { + foreach (FileInfo file in directoryInfo.GetFiles()) + { + return true; + } + + foreach (DirectoryInfo folder in directoryInfo.GetDirectories()) + { + return FolderContainsFiles(folder); + } + + return false; + } + + /// + /// Determines whether the current state still contains a card for the content name. + /// + /// The current launcher content state. + /// The content name. + /// when a card still exists. + private static bool ContentCardExists(LauncherContentState state, string contentName) + { + return ContainsContentName(state.Modifications, contentName) || + ContainsContentName(state.Addons, contentName) || + ContainsContentName(state.Patches, contentName); + } + + /// + /// Determines whether entries contain a content name. + /// + /// The entries to inspect. + /// The content name. + /// when an entry has the content name. + private static bool ContainsContentName(IEnumerable entries, string contentName) + { + return entries.Any(entry => String.Equals(entry.Name, contentName, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Deletes image files with a specific base name. + /// + /// The image folder path. + /// The image file base name. + private void DeleteImageFiles(string imageFolderPath, string imageBaseName) + { + foreach (string imageFilePath in Directory.EnumerateFiles(imageFolderPath, imageBaseName + ".*")) + { + try + { + File.Delete(imageFilePath); + _logger.LogInformation( + "Deleted cached modification image {ImageFileName}.", + Path.GetFileName(imageFilePath)); + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to delete cached modification image {ImageFileName}.", + Path.GetFileName(imageFilePath)); + } + } + } + + /// + /// Deletes an image folder when it has no remaining entries. + /// + /// The image folder path. + private void DeleteImageFolderIfEmpty(string imageFolderPath) + { + try + { + if (!Directory.EnumerateFileSystemEntries(imageFolderPath).Any()) + { + Directory.Delete(imageFolderPath); + _logger.LogInformation( + "Deleted empty modification image cache folder {ImageFolderName}.", + Path.GetFileName(imageFolderPath)); + } + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to delete empty modification image cache folder {ImageFolderName}.", + Path.GetFileName(imageFolderPath)); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/FileSystemManualModificationImporter.cs b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemManualModificationImporter.cs new file mode 100644 index 00000000..649fe48d --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemManualModificationImporter.cs @@ -0,0 +1,133 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using GenLauncherGO.Core.Archives; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Imports manually selected modification files by copying files, extracting supported archives, and converting loose +/// .big packages to launcher-managed .gib files. +/// +public sealed class FileSystemManualModificationImporter : IManualModificationImporter +{ + /// + /// Identifies archive extensions accepted by the legacy manual import dialog. + /// + private static readonly HashSet _archiveExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".rar", + ".7z", + ".zip", + }; + + /// + /// Extracts supported archive files into the destination content folder. + /// + private readonly IArchiveExtractor _archiveExtractor; + + /// + /// Logs manual import file-system diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The archive extractor used for supported archive files. + /// The logger used for manual import diagnostics. + public FileSystemManualModificationImporter( + IArchiveExtractor archiveExtractor, + ILogger logger) + { + _archiveExtractor = archiveExtractor ?? throw new ArgumentNullException(nameof(archiveExtractor)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public void Import( + ManualModificationImportRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(request.SourceFilePaths); + ArgumentException.ThrowIfNullOrWhiteSpace(request.DestinationDirectory); + + if (request.SourceFilePaths.Count == 0) + { + throw new ArgumentException("At least one source file is required.", nameof(request)); + } + + string destinationDirectory = Path.GetFullPath(request.DestinationDirectory); + try + { + Directory.CreateDirectory(destinationDirectory); + + foreach (string sourceFilePath in request.SourceFilePaths) + { + cancellationToken.ThrowIfCancellationRequested(); + ImportFile(sourceFilePath, destinationDirectory, cancellationToken); + } + + _logger.LogInformation( + "Imported {FileCount} manual content file(s) to {DestinationDirectory}.", + request.SourceFilePaths.Count, + Path.GetFileName(destinationDirectory)); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + _logger.LogError( + exception, + "Failed to import manual content into {DestinationDirectory}.", + Path.GetFileName(destinationDirectory)); + throw; + } + } + + /// + /// Imports one selected source file into the destination directory. + /// + /// The selected source file path. + /// The fully qualified destination directory. + /// A token that can cancel archive extraction. + private void ImportFile( + string sourceFilePath, + string destinationDirectory, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sourceFilePath); + + string sourceFileName = Path.GetFileName(sourceFilePath); + if (string.IsNullOrWhiteSpace(sourceFileName)) + { + throw new ArgumentException("Source file path must include a file name.", nameof(sourceFilePath)); + } + + string destinationFilePath = Path.Combine(destinationDirectory, sourceFileName); + string extension = Path.GetExtension(sourceFileName); + + if (!File.Exists(destinationFilePath)) + { + File.Copy(sourceFilePath, destinationFilePath); + } + + if (_archiveExtensions.Contains(extension)) + { + _archiveExtractor.ExtractToDirectory( + destinationFilePath, + destinationDirectory, + cancellationToken: cancellationToken); + File.Delete(destinationFilePath); + return; + } + + if (string.Equals(extension, ".big", StringComparison.OrdinalIgnoreCase)) + { + File.Move(destinationFilePath, Path.ChangeExtension(destinationFilePath, ".gib")); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/FileSystemModificationImageFileService.cs b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemModificationImageFileService.cs new file mode 100644 index 00000000..d8470348 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemModificationImageFileService.cs @@ -0,0 +1,178 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Manages cached modification image files on disk. +/// +public sealed class FileSystemModificationImageFileService : IModificationImageFileService +{ + /// + /// Resolves launcher image cache paths. + /// + private readonly LauncherPaths _launcherPaths; + + /// + /// Logs diagnostics for image cache file-system side effects. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The launcher runtime paths. + /// The logger used for image cache diagnostics. + public FileSystemModificationImageFileService( + LauncherPaths launcherPaths, + ILogger logger) + { + _launcherPaths = launcherPaths ?? throw new ArgumentNullException(nameof(launcherPaths)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public string? FindExistingImageFilePath(string modificationName, string imageBaseName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(modificationName); + ArgumentException.ThrowIfNullOrWhiteSpace(imageBaseName); + + string imageDirectory = _launcherPaths.GetModificationImagesDirectory(modificationName); + if (!Directory.Exists(imageDirectory)) + { + return null; + } + + return Directory.EnumerateFiles(imageDirectory, imageBaseName + ".*").FirstOrDefault(); + } + + /// + public int CountImageFiles(string modificationName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(modificationName); + + string imageDirectory = _launcherPaths.GetModificationImagesDirectory(modificationName); + if (!Directory.Exists(imageDirectory)) + { + return 0; + } + + return Directory.EnumerateFiles(imageDirectory).Count(); + } + + /// + public bool ImageExists(string? imageFilePath) + { + return !string.IsNullOrWhiteSpace(imageFilePath) && File.Exists(imageFilePath); + } + + /// + public bool TryDeleteImage(string? imageFilePath) + { + if (string.IsNullOrWhiteSpace(imageFilePath)) + { + return true; + } + + try + { + if (File.Exists(imageFilePath)) + { + File.Delete(imageFilePath); + } + + return true; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException + or ArgumentException or NotSupportedException) + { + _logger.LogWarning( + exception, + "Could not remove cached modification image {ImageFileName}.", + Path.GetFileName(imageFilePath)); + return false; + } + } + + /// + public Task ReplaceImageAsync( + ModificationImageReplacementRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + return Task.Run(() => ReplaceImage(request, cancellationToken), cancellationToken); + } + + /// + /// Replaces the cached image file and removes stale sibling extensions. + /// + /// The image replacement request. + /// A token that cancels the replacement before the next file operation. + /// The destination image file path. + private string ReplaceImage( + ModificationImageReplacementRequest request, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + string extension = Path.GetExtension(request.SourceImagePath); + if (string.IsNullOrWhiteSpace(extension)) + { + throw new ArgumentException( + "The source image must have a file extension.", + nameof(request)); + } + + string destinationPath = _launcherPaths.GetModificationImageFilePath( + request.ModificationName, + request.ImageBaseName + extension); + string destinationDirectory = _launcherPaths.GetModificationImagesDirectory(request.ModificationName); + string sourcePath = Path.GetFullPath(request.SourceImagePath); + string fullDestinationPath = Path.GetFullPath(destinationPath); + + if (string.Equals(sourcePath, fullDestinationPath, StringComparison.OrdinalIgnoreCase)) + { + return destinationPath; + } + + try + { + Directory.CreateDirectory(destinationDirectory); + string imageSearchPattern = Path.GetFileNameWithoutExtension(destinationPath) + ".*"; + foreach (string existingImagePath in Directory.EnumerateFiles(destinationDirectory, imageSearchPattern)) + { + cancellationToken.ThrowIfCancellationRequested(); + File.Delete(existingImagePath); + } + + cancellationToken.ThrowIfCancellationRequested(); + File.Copy(request.SourceImagePath, destinationPath); + return destinationPath; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException + or ArgumentException or NotSupportedException) + { + _logger.LogError( + exception, + "Could not replace cached modification image {ImageBaseName} for {ModificationName}.", + request.ImageBaseName, + request.ModificationName); + throw new IOException( + string.Format( + CultureInfo.InvariantCulture, + "Could not replace cached image '{0}' for modification '{1}'.", + request.ImageBaseName, + request.ModificationName), + exception); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/LauncherCatalogImageCache.cs b/GenLauncherGO.Infrastructure/Mods/Services/LauncherCatalogImageCache.cs new file mode 100644 index 00000000..89774dc7 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/LauncherCatalogImageCache.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Remote; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Contracts; +using GenLauncherGO.Infrastructure.Mods.Models; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Caches remote launcher catalog images on disk. +/// +internal sealed class LauncherCatalogImageCache : ILauncherCatalogImageCache +{ + /// + /// The remote asset downloader used for cached launcher images. + /// + private readonly IRemoteAssetDownloader _assetDownloader; + + /// + /// The logger used for image cache diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The remote asset downloader used for cached launcher images. + /// The logger used for image cache diagnostics. + public LauncherCatalogImageCache( + IRemoteAssetDownloader assetDownloader, + ILogger logger) + { + _assetDownloader = assetDownloader ?? throw new ArgumentNullException(nameof(assetDownloader)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task CacheModificationImagesAsync( + RemoteContentManifest modification, + LauncherPaths paths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(modification); + ArgumentNullException.ThrowIfNull(paths); + + var imageDownloads = new List(capacity: 2); + if (!String.IsNullOrEmpty(modification.ImageSourceLink)) + { + imageDownloads.Add(DownloadImageIfMissingAsync( + paths, + modification.Name, + modification.Version, + modification.ImageSourceLink, + cancellationToken)); + } + + if (modification.ColorsInformation != null && + !String.IsNullOrEmpty(modification.ColorsInformation.GenLauncherBackgroundImageLink)) + { + imageDownloads.Add(DownloadImageIfMissingAsync( + paths, + modification.Name, + modification.Version + "-background", + modification.ColorsInformation.GenLauncherBackgroundImageLink, + cancellationToken)); + } + + await Task.WhenAll(imageDownloads).ConfigureAwait(false); + } + + /// + public async Task CacheAdvertisingImagesAsync( + RemoteAdvertisingReference advertising, + LauncherPaths paths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(advertising); + ArgumentNullException.ThrowIfNull(paths); + + RemoveStaleAdvertisingImages(advertising, paths); + + var imageDownloads = new List(advertising.ImageUrls.Count); + int imageIndex = 0; + foreach (string imageLink in advertising.ImageUrls) + { + int currentImageIndex = imageIndex; + imageDownloads.Add(DownloadImageIfMissingAsync( + paths, + advertising.Name, + currentImageIndex.ToString(), + imageLink, + cancellationToken)); + imageIndex++; + } + + await Task.WhenAll(imageDownloads).ConfigureAwait(false); + } + + /// + /// Removes stale advertising image files when the remote image count changes. + /// + /// The advertising image metadata. + /// The launcher paths used to resolve cache destinations. + private void RemoveStaleAdvertisingImages(RemoteAdvertisingReference advertising, LauncherPaths paths) + { + string folderName = advertising.Name.Trim(Path.GetInvalidFileNameChars()); + string imageFolderPath = paths.GetModificationImagesDirectory(folderName); + + if (!Directory.Exists(imageFolderPath)) + { + return; + } + + var dirInfo = new DirectoryInfo(imageFolderPath); + FileInfo[] images = dirInfo.GetFiles(); + if (images.Length == advertising.ImageUrls.Count) + { + return; + } + + foreach (FileInfo image in images) + { + try + { + image.Delete(); + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to delete stale advertising image {ImageFileName}.", + image.Name); + } + } + } + + /// + /// Downloads one image when the local cache file is missing. + /// + /// The launcher paths used to resolve cache destinations. + /// The modification name. + /// The cache file base name. + /// The remote image link. + /// The token used to cancel remote work. + private async Task DownloadImageIfMissingAsync( + LauncherPaths paths, + string modificationName, + string fileName, + string link, + CancellationToken cancellationToken) + { + try + { + string extension = GetImageExtensionFromLink(link); + await _assetDownloader.DownloadIfMissingAsync( + new Uri(link, UriKind.Absolute), + paths.GetModificationImageFilePath(modificationName, fileName + extension), + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to download cached image {ImageName} for {ModificationName}.", + fileName, + modificationName); + } + } + + /// + /// Gets a safe image extension from a remote link. + /// + /// The remote image link. + /// The supported extension, or .png when no supported extension can be found. + private static string GetImageExtensionFromLink(string link) + { + if (Uri.TryCreate(link, UriKind.Absolute, out Uri? uri)) + { + string extension = Path.GetExtension(uri.LocalPath); + if (IsSupportedImageExtension(extension)) + { + return extension; + } + } + + return ".png"; + } + + /// + /// Determines whether an image extension is supported by the launcher cache. + /// + /// The extension to inspect. + /// when the extension is supported. + private static bool IsSupportedImageExtension(string extension) + { + return String.Equals(extension, ".png", StringComparison.OrdinalIgnoreCase) || + String.Equals(extension, ".jpg", StringComparison.OrdinalIgnoreCase) || + String.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentCatalogService.cs b/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentCatalogService.cs new file mode 100644 index 00000000..7d385d25 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentCatalogService.cs @@ -0,0 +1,646 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Contracts; +using GenLauncherGO.Infrastructure.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Support; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Coordinates remote launcher catalog data, local content state, cached images, and selection persistence. +/// +internal sealed class LauncherContentCatalogService : ILauncherContentCatalogService +{ + /// + /// The maximum number of modification image cache updates allowed at once during startup. + /// + private const int MaxConcurrentImageCacheUpdates = 8; + + /// + /// The local content-state store. + /// + private readonly ILauncherContentStateStore _contentStateStore; + + /// + /// The remote catalog client used for legacy-compatible YAML manifests. + /// + private readonly IRemoteLauncherCatalogClient _remoteCatalogClient; + + /// + /// The image cache used for card, background, and advertising images. + /// + private readonly ILauncherCatalogImageCache _imageCache; + + /// + /// The mapper used to convert compact state and mutable catalog models. + /// + private readonly ILauncherContentStateMapper _stateMapper; + + /// + /// The reconciler used to align catalog state with local folders. + /// + private readonly ILauncherLocalContentReconciler _localContentReconciler; + + /// + /// The selection query service. + /// + private readonly LauncherContentSelectionService _selectionService; + + /// + /// The logger used for catalog initialization diagnostics. + /// + private readonly ILogger _logger; + + /// + /// The current launcher content catalog and local state. + /// + private LauncherData _data = new(); + + /// + /// Remote patch and add-on metadata keyed by parent modification name. + /// + private Dictionary _modificationsAndAddons = + new(StringComparer.OrdinalIgnoreCase); + + /// + /// Remote modification names whose child content has already been read. + /// + private readonly HashSet _downloadedModsInfo = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Remote content versions already downloaded into the in-memory catalog. + /// + private readonly HashSet _downloadedReposContent = new(); + + /// + /// Advertising entries from the normalized remote catalog. + /// + private IReadOnlyList _advertisingData = Array.Empty(); + + /// + /// Original-game patch manifest links from the top-level remote manifest. + /// + private IReadOnlyList _originalGamePatches = Array.Empty(); + + /// + /// Original-game add-on manifest links from the top-level remote manifest. + /// + private IReadOnlyList _originalGameAddons = Array.Empty(); + + /// + /// The active advertising modification metadata. + /// + private RemoteContentManifest? _advertising; + + /// + /// A value indicating whether remote repository data is currently available. + /// + private bool _connected; + + /// + /// The resolved launcher paths for the current session. + /// + private LauncherPaths? _paths; + + /// + /// The launcher content folder layout for the current session. + /// + private LauncherContentLayout? _layout; + + /// + /// The normalized top-level remote repository catalog. + /// + private RemoteLauncherCatalog? _repositoryData; + + /// + /// Initializes a new instance of the class. + /// + /// The local content-state store. + /// The remote catalog client used for legacy-compatible YAML manifests. + /// The image cache used for card, background, and advertising images. + /// The mapper used to convert compact state and mutable catalog models. + /// The reconciler used to align catalog state with local folders. + /// The selection query service. + /// The logger used for catalog initialization diagnostics. + public LauncherContentCatalogService( + ILauncherContentStateStore contentStateStore, + IRemoteLauncherCatalogClient remoteCatalogClient, + ILauncherCatalogImageCache imageCache, + ILauncherContentStateMapper stateMapper, + ILauncherLocalContentReconciler localContentReconciler, + LauncherContentSelectionService selectionService, + ILogger logger) + { + _contentStateStore = contentStateStore ?? throw new ArgumentNullException(nameof(contentStateStore)); + _remoteCatalogClient = remoteCatalogClient ?? throw new ArgumentNullException(nameof(remoteCatalogClient)); + _imageCache = imageCache ?? throw new ArgumentNullException(nameof(imageCache)); + _stateMapper = stateMapper ?? throw new ArgumentNullException(nameof(stateMapper)); + _localContentReconciler = + localContentReconciler ?? throw new ArgumentNullException(nameof(localContentReconciler)); + _selectionService = selectionService ?? throw new ArgumentNullException(nameof(selectionService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public IReadOnlyList? ReposModsNames { get; private set; } + + /// + public async Task InitDataAsync( + LauncherContentCatalogInitializationRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(request.Paths); + ArgumentNullException.ThrowIfNull(request.Layout); + + _connected = request.Connected; + _paths = request.Paths; + _layout = request.Layout; + + if (_connected) + { + if (request.RemoteManifestUri is null) + { + throw new ArgumentException( + "A remote manifest URI is required when initializing a connected catalog.", + nameof(request)); + } + + await ReadMainManifestAsync(request.RemoteManifestUri, cancellationToken).ConfigureAwait(false); + } + + ReadLocalModsData(); + UpdateLocalModificationsData(); + + if (!_connected || _repositoryData is null) + { + LogCatalogInitialized(); + return; + } + + var installedMods = GetMods().Select(mod => mod.Name).ToList(); + ReposModsNames = _remoteCatalogClient.GetModificationNames(_repositoryData); + + IReadOnlyList installedManifests = await _remoteCatalogClient + .DownloadInstalledModDataAsync( + _repositoryData, + installedMods, + cancellationToken).ConfigureAwait(false); + _modificationsAndAddons = ToManifestDictionary(installedManifests); + var reposMods = installedManifests.Select(manifest => manifest.Content).ToList(); + + await CacheInstalledModificationImagesAsync(reposMods, cancellationToken).ConfigureAwait(false); + + foreach (RemoteContentManifest reposMod in reposMods) + { + AddDownloadedModificationData(reposMod); + } + + GameModification? selectedMod = GetSelectedMod(); + if (selectedMod != null) + { + await ReadPatchesAndAddonsForModAsync(selectedMod, cancellationToken).ConfigureAwait(false); + } + + LogCatalogInitialized(); + } + + /// + public async Task ReadOriginalGameAddonsAndPatchesAsync(CancellationToken cancellationToken) + { + if (!_connected || _repositoryData is null) + { + return; + } + + const string originalGameName = "Original Game"; + if (_downloadedModsInfo.Contains(originalGameName)) + { + return; + } + + _downloadedModsInfo.Add(originalGameName); + + Task> reposPatchesTask = _remoteCatalogClient.ReadChildManifestsAsync( + _originalGamePatches, + cancellationToken); + Task> reposAddonsTask = _remoteCatalogClient.ReadChildManifestsAsync( + _originalGameAddons, + cancellationToken); + IReadOnlyList[] childManifests = + await Task.WhenAll(reposPatchesTask, reposAddonsTask).ConfigureAwait(false); + IReadOnlyList reposPatches = childManifests[0]; + IReadOnlyList reposAddons = childManifests[1]; + + foreach (RemoteContentManifest patch in reposPatches) + { + var add = RemoteLauncherCatalogMapper.ToModificationVersion(patch); + add.DependenceName = originalGameName; + _data.AddOrUpdate(add); + _downloadedReposContent.Add(RemoteLauncherCatalogMapper.ToModificationVersion(patch)); + } + + foreach (RemoteContentManifest addon in reposAddons) + { + var add = RemoteLauncherCatalogMapper.ToModificationVersion(addon); + add.DependenceName = originalGameName; + _data.AddOrUpdate(add); + _downloadedReposContent.Add(RemoteLauncherCatalogMapper.ToModificationVersion(addon)); + } + } + + /// + public void AddModModification(ModificationVersion modification) + { + ArgumentNullException.ThrowIfNull(modification); + + _data.AddOrUpdate(modification); + } + + /// + public void UnselectAllModifications() + { + _selectionService.UnselectAllModifications(_data); + } + + /// + public async Task DownloadModificationDataFromReposAsync( + string name, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + RemoteModificationManifest manifest = + await _remoteCatalogClient.DownloadModDataByNameAsync( + _repositoryData ?? RemoteLauncherCatalog.Empty, + name, + cancellationToken).ConfigureAwait(false); + AddRemoteModificationManifest(manifest); + + await _imageCache.CacheModificationImagesAsync(manifest.Content, Paths, cancellationToken) + .ConfigureAwait(false); + AddDownloadedModificationData(manifest.Content); + return RemoteLauncherCatalogMapper.ToModificationVersion(manifest.Content); + } + + /// + public IReadOnlyList GetMods() + { + return _data.Modifications; + } + + /// + public ModificationVersion? GetAdvertising() + { + return _advertising != null ? RemoteLauncherCatalogMapper.ToModificationVersion(_advertising) : null; + } + + /// + public GameModification? GetSelectedMod() + { + return _selectionService.GetSelectedMod(_data); + } + + /// + public ModificationVersion? GetSelectedModVersion() + { + return _selectionService.GetSelectedModVersion(_data); + } + + /// + public ModificationVersion? GetSelectedPatchVersion() + { + return _selectionService.GetSelectedPatchVersion(_data); + } + + /// + public IReadOnlyList GetAllModificationsNames() + { + return _selectionService.GetAllModificationsNames(_data); + } + + /// + public IReadOnlyList GetPatchesForSelectedMod() + { + return _selectionService.GetPatchesForSelectedMod(_data); + } + + /// + public IReadOnlyList GetAddonsForSelectedMod() + { + return _selectionService.GetAddonsForSelectedMod(_data); + } + + /// + public IReadOnlyList GetSelectedModVersions() + { + return _selectionService.GetSelectedModVersions(_data); + } + + /// + public IReadOnlyList GetSelectedAddonsVersions() + { + return _selectionService.GetSelectedAddonsVersions(_data); + } + + /// + public IReadOnlyList GetSelectedAddonsForSelectedMod() + { + return _selectionService.GetSelectedAddonsForSelectedMod(_data); + } + + /// + public GameModification? GetSelectedPatch() + { + return _selectionService.GetSelectedPatch(_data); + } + + /// + public IReadOnlyList GetAllModsVersionsList() + { + return _selectionService.GetAllModsVersionsList(_data); + } + + /// + public IReadOnlyList GetAddonVersionsForModList(string modName) + { + return _selectionService.GetAddonVersionsForModList(_data, modName); + } + + /// + public IReadOnlyList GetPatchVersionsForModList(string modName) + { + return _selectionService.GetPatchVersionsForModList(_data, modName); + } + + /// + public void DeleteVersion(ModificationVersion version) + { + DeleteModificationVersion(version); + } + + /// + public void DeleteModificationVersion(ModificationVersion modificationVersion) + { + _localContentReconciler.DeleteVersion(_data, modificationVersion, Paths, Layout); + } + + /// + public void RemoveContentVersion(ModificationVersion modificationVersion) + { + _localContentReconciler.DeleteVersion(_data, modificationVersion, Paths, Layout); + _data.Delete(modificationVersion); + } + + /// + public void RemoveContent(ModificationVersion modificationVersion) + { + _localContentReconciler.DeleteContent(_data, modificationVersion, Paths, Layout); + _data.Delete(modificationVersion); + } + + /// + public async Task ReadPatchesAndAddonsForModAsync( + ModificationReposVersion modification, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(modification); + + if (!_connected || _repositoryData is null) + { + return; + } + + string keyModification = modification.Name ?? string.Empty; + + if (_downloadedModsInfo.Contains(keyModification)) + { + return; + } + + _downloadedModsInfo.Add(keyModification); + + if (!_modificationsAndAddons.TryGetValue(keyModification, out RemoteModificationManifest? modData)) + { + return; + } + + Task> reposPatchesTask = _remoteCatalogClient.ReadChildManifestsAsync( + modData.PatchManifestUrls, + cancellationToken); + Task> reposAddonsTask = _remoteCatalogClient.ReadChildManifestsAsync( + modData.AddonManifestUrls, + cancellationToken); + IReadOnlyList[] childManifests = + await Task.WhenAll(reposPatchesTask, reposAddonsTask).ConfigureAwait(false); + IReadOnlyList reposPatches = childManifests[0]; + IReadOnlyList reposAddons = childManifests[1]; + + foreach (RemoteContentManifest patch in reposPatches) + { + AddDownloadedModificationData(patch); + } + + foreach (RemoteContentManifest addon in reposAddons) + { + AddDownloadedModificationData(addon); + } + } + + /// + public void UpdateLocalModificationsData() + { + _localContentReconciler.Reconcile(_data, _downloadedReposContent, Paths, Layout); + } + + /// + public void ReadLocalModsData() + { + _data = _stateMapper.ToLauncherData(_contentStateStore.Load()); + } + + /// + public void SaveLauncherData() + { + _contentStateStore.Save(_stateMapper.ToLauncherContentState(_data ?? new LauncherData())); + } + + /// + /// Gets initialized launcher paths or throws when the catalog has not been initialized. + /// + private LauncherPaths Paths => + _paths ?? throw new InvalidOperationException("Launcher content catalog has not been initialized."); + + /// + /// Gets initialized content layout or throws when the catalog has not been initialized. + /// + private LauncherContentLayout Layout => + _layout ?? throw new InvalidOperationException("Launcher content catalog has not been initialized."); + + /// + /// Reads the top-level remote manifest and related advertising metadata. + /// + /// The top-level manifest URI. + /// The token used to cancel remote work. + private async Task ReadMainManifestAsync(Uri manifestUri, CancellationToken cancellationToken) + { + _repositoryData = await _remoteCatalogClient.ReadCatalogAsync( + manifestUri, + cancellationToken).ConfigureAwait(false); + + _advertisingData = _repositoryData.AdvertisingEntries; + + if (_advertisingData.Count > 0) + { + await DownloadAdvertisingDataAsync(cancellationToken).ConfigureAwait(false); + } + + if (_repositoryData.OriginalGamePatchManifestUrls.Count > 0) + { + _originalGamePatches = _repositoryData.OriginalGamePatchManifestUrls; + } + + if (_repositoryData.OriginalGameAddonManifestUrls.Count > 0) + { + _originalGameAddons = _repositoryData.OriginalGameAddonManifestUrls; + } + } + + /// + /// Adds downloaded remote metadata to the current catalog. + /// + /// The remote modification metadata. + private void AddDownloadedModificationData(RemoteContentManifest manifest) + { + var version = RemoteLauncherCatalogMapper.ToModificationVersion(manifest); + _data.AddOrUpdate(version); + _downloadedReposContent.Add(RemoteLauncherCatalogMapper.ToModificationVersion(manifest)); + } + + /// + /// Caches installed modification images with bounded parallelism. + /// + /// The installed remote modification metadata. + /// The token used to cancel image cache work. + private async Task CacheInstalledModificationImagesAsync( + IReadOnlyList modifications, + CancellationToken cancellationToken) + { + using var semaphore = new SemaphoreSlim(MaxConcurrentImageCacheUpdates); + await Task.WhenAll(modifications.Select(modification => CacheModificationImagesAsync( + modification, + semaphore, + cancellationToken))).ConfigureAwait(false); + } + + /// + /// Caches one modification's images while respecting the startup cache concurrency limit. + /// + /// The remote modification metadata. + /// The concurrency gate. + /// The token used to cancel image cache work. + private async Task CacheModificationImagesAsync( + RemoteContentManifest modification, + SemaphoreSlim semaphore, + CancellationToken cancellationToken) + { + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await _imageCache.CacheModificationImagesAsync(modification, Paths, cancellationToken) + .ConfigureAwait(false); + } + finally + { + semaphore.Release(); + } + } + + /// + /// Downloads advertising metadata and image assets. + /// + /// The token used to cancel remote work. + private async Task DownloadAdvertisingDataAsync(CancellationToken cancellationToken) + { + RemoteAdvertisingReference advData = _advertisingData[0]; + _advertising = await _remoteCatalogClient.DownloadAdvertisingInfoAsync( + advData.ManifestUrl, + cancellationToken).ConfigureAwait(false); + if (_advertising is null) + { + return; + } + + await _imageCache.CacheAdvertisingImagesAsync(advData, Paths, cancellationToken).ConfigureAwait(false); + } + + /// + /// Builds a case-insensitive manifest dictionary keyed by content name. + /// + /// The remote manifests. + /// The manifest dictionary. + private static Dictionary ToManifestDictionary( + IEnumerable manifests) + { + var result = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (RemoteModificationManifest manifest in manifests) + { + string key = manifest.Content.Name ?? string.Empty; + if (!result.ContainsKey(key)) + { + result.Add(key, manifest); + } + } + + return result; + } + + /// + /// Adds one remote manifest to the child-content lookup when it is not already present. + /// + /// The remote manifest. + private void AddRemoteModificationManifest(RemoteModificationManifest manifest) + { + string key = manifest.Content.Name ?? string.Empty; + if (!_modificationsAndAddons.ContainsKey(key)) + { + _modificationsAndAddons.Add(key, manifest); + } + } + + /// + /// Logs a compact catalog initialization summary without local paths or remote URLs. + /// + private void LogCatalogInitialized() + { + _logger.LogInformation( + "Initialized launcher content catalog. Connected: {Connected}; modifications: {ModificationCount}; " + + "patches: {PatchCount}; add-ons: {AddonCount}; versions: {VersionCount}; " + + "repository modifications: {RepositoryModificationCount}.", + _connected, + _data.Modifications.Count, + _data.Patches.Count, + _data.Addons.Count, + CountVersions(_data), + ReposModsNames?.Count ?? 0); + } + + /// + /// Counts all catalog versions across modification, patch, and add-on cards. + /// + /// The mutable launcher catalog. + /// The total number of known versions. + private static int CountVersions(LauncherData data) + { + return data.Modifications.Sum(modification => modification.ModificationVersions.Count) + + data.Patches.Sum(modification => modification.ModificationVersions.Count) + + data.Addons.Sum(modification => modification.ModificationVersions.Count); + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentSelectionService.cs b/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentSelectionService.cs new file mode 100644 index 00000000..23f9ba2b --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentSelectionService.cs @@ -0,0 +1,233 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Provides legacy-compatible selection queries over the mutable launcher catalog. +/// +internal sealed class LauncherContentSelectionService +{ + /// + /// Clears selected state from all modification cards. + /// + /// The mutable launcher catalog. + public void UnselectAllModifications(LauncherData launcherData) + { + ArgumentNullException.ThrowIfNull(launcherData); + + foreach (GameModification mod in launcherData.Modifications) + { + mod.IsSelected = false; + } + } + + /// + /// Gets the selected modification card. + /// + /// The mutable launcher catalog. + /// The selected modification card, or when none is selected. + public GameModification? GetSelectedMod(LauncherData launcherData) + { + ArgumentNullException.ThrowIfNull(launcherData); + + return launcherData.Modifications.FirstOrDefault(mod => mod.IsSelected); + } + + /// + /// Gets the selected modification version. + /// + /// The mutable launcher catalog. + /// The selected modification version, or when none is selected. + public ModificationVersion? GetSelectedModVersion(LauncherData launcherData) + { + GameModification? selectedMod = GetSelectedMod(launcherData); + return selectedMod?.ModificationVersions.FirstOrDefault(mod => mod.IsSelected); + } + + /// + /// Gets the selected patch version. + /// + /// The mutable launcher catalog. + /// The selected patch version, or when none is selected. + public ModificationVersion? GetSelectedPatchVersion(LauncherData launcherData) + { + return GetPatchesForSelectedMod(launcherData) + .Where(mod => mod.IsSelected) + .SelectMany(mod => mod.ModificationVersions) + .FirstOrDefault(mod => mod.IsSelected); + } + + /// + /// Gets all modification names. + /// + /// The mutable launcher catalog. + /// The current modification names. + public IReadOnlyList GetAllModificationsNames(LauncherData launcherData) + { + ArgumentNullException.ThrowIfNull(launcherData); + + return launcherData.Modifications.Select(mod => mod.Name).ToList(); + } + + /// + /// Gets patches that belong to the selected modification or original game. + /// + /// The mutable launcher catalog. + /// The matching patch cards. + public IReadOnlyList GetPatchesForSelectedMod(LauncherData launcherData) + { + ArgumentNullException.ThrowIfNull(launcherData); + + GameModification? selectedMod = GetSelectedMod(launcherData); + + if (selectedMod != null) + { + return launcherData.Patches.Where(mod => + String.Equals(mod.DependenceName, selectedMod.Name, StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + return launcherData.Patches.Where(mod => + String.Equals(mod.DependenceName, "Original game", StringComparison.OrdinalIgnoreCase)) + .ToList(); + } + + /// + /// Gets add-ons that belong to the selected modification or selected patch. + /// + /// The mutable launcher catalog. + /// The matching add-on cards. + public IReadOnlyList GetAddonsForSelectedMod(LauncherData launcherData) + { + ArgumentNullException.ThrowIfNull(launcherData); + + GameModification? selectedMod = GetSelectedMod(launcherData); + + if (selectedMod != null) + { + return launcherData.Addons.Where(mod => + String.Equals(mod.DependenceName, selectedMod.Name, StringComparison.OrdinalIgnoreCase)) + .Union(launcherData.Addons.Where(mod => + String.Equals(mod.DependenceName, GetSelectedPatch(launcherData)?.Name, + StringComparison.OrdinalIgnoreCase))) + .ToList(); + } + + return launcherData.Addons.Where(mod => + String.Equals(mod.DependenceName, "Original game", StringComparison.OrdinalIgnoreCase)) + .Union(launcherData.Addons.Where(mod => + String.Equals(mod.DependenceName, GetSelectedPatch(launcherData)?.Name, + StringComparison.OrdinalIgnoreCase))) + .ToList(); + } + + /// + /// Gets all versions for the selected modification. + /// + /// The mutable launcher catalog. + /// The selected modification versions. + public IReadOnlyList GetSelectedModVersions(LauncherData launcherData) + { + return GetSelectedMod(launcherData)?.ModificationVersions ?? new List(); + } + + /// + /// Gets selected add-on versions. + /// + /// The mutable launcher catalog. + /// The selected add-on versions. + public IReadOnlyList GetSelectedAddonsVersions(LauncherData launcherData) + { + ArgumentNullException.ThrowIfNull(launcherData); + + return GetAddonsForSelectedMod(launcherData) + .Where(mod => mod.IsSelected) + .SelectMany(mod => mod.ModificationVersions.Where(version => version.IsSelected)) + .Union(launcherData.Addons.Where(mod => + String.Equals(mod.DependenceName, GetSelectedPatch(launcherData)?.Name, + StringComparison.OrdinalIgnoreCase)) + .Where(mod => mod.IsSelected) + .SelectMany(mod => mod.ModificationVersions.Where(version => version.IsSelected))) + .ToList(); + } + + /// + /// Gets selected add-on cards for the selected modification. + /// + /// The mutable launcher catalog. + /// The selected add-on cards. + public IReadOnlyList GetSelectedAddonsForSelectedMod(LauncherData launcherData) + { + return GetAddonsForSelectedMod(launcherData).Where(mod => mod.IsSelected).ToList(); + } + + /// + /// Gets the selected patch card. + /// + /// The mutable launcher catalog. + /// The selected patch card, or when none is selected. + public GameModification? GetSelectedPatch(LauncherData launcherData) + { + return GetPatchesForSelectedMod(launcherData).FirstOrDefault(mod => mod.IsSelected); + } + + /// + /// Gets all modification versions. + /// + /// The mutable launcher catalog. + /// All modification versions. + public IReadOnlyList GetAllModsVersionsList(LauncherData launcherData) + { + ArgumentNullException.ThrowIfNull(launcherData); + + return launcherData.Modifications + .Select(mod => mod.ModificationVersions) + .SelectMany(mod => mod) + .ToList(); + } + + /// + /// Gets add-on versions associated with a modification name. + /// + /// The mutable launcher catalog. + /// The modification name. + /// The matching add-on versions. + public IReadOnlyList GetAddonVersionsForModList( + LauncherData launcherData, + string modName) + { + ArgumentNullException.ThrowIfNull(launcherData); + + return launcherData.Addons + .Where(mod => String.Equals(mod.DependenceName, modName, StringComparison.OrdinalIgnoreCase)) + .SelectMany(mod => mod.ModificationVersions) + .Union(launcherData.Addons + .Where(mod => String.Equals( + mod.DependenceName, + GetSelectedPatchVersion(launcherData)?.Name, + StringComparison.OrdinalIgnoreCase)) + .SelectMany(mod => mod.ModificationVersions)) + .ToList(); + } + + /// + /// Gets patch versions associated with a modification name. + /// + /// The mutable launcher catalog. + /// The modification name. + /// The matching patch versions. + public IReadOnlyList GetPatchVersionsForModList( + LauncherData launcherData, + string modName) + { + ArgumentNullException.ThrowIfNull(launcherData); + + return launcherData.Patches + .Where(mod => String.Equals(mod.DependenceName, modName, StringComparison.OrdinalIgnoreCase)) + .SelectMany(mod => mod.ModificationVersions) + .ToList(); + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentStateMapper.cs b/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentStateMapper.cs new file mode 100644 index 00000000..cc3a7bc6 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentStateMapper.cs @@ -0,0 +1,298 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Contracts; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Maps compact launcher content state to and from legacy-compatible catalog models. +/// +internal sealed class LauncherContentStateMapper : ILauncherContentStateMapper +{ + /// + public LauncherData ToLauncherData(LauncherContentState state) + { + ArgumentNullException.ThrowIfNull(state); + + var launcherData = new LauncherData(); + AddStoredVersions(launcherData, state.Modifications, LauncherContentType.Mod); + AddStoredVersions(launcherData, state.Addons, LauncherContentType.Addon); + AddStoredVersions(launcherData, state.Patches, LauncherContentType.Patch); + return launcherData; + } + + /// + public LauncherContentState ToLauncherContentState(LauncherData launcherData) + { + ArgumentNullException.ThrowIfNull(launcherData); + + return new LauncherContentState + { + Modifications = ToEntryStates(launcherData.Modifications, LauncherContentType.Mod), + Addons = ToEntryStates(launcherData.Addons, LauncherContentType.Addon), + Patches = ToEntryStates(launcherData.Patches, LauncherContentType.Patch) + }; + } + + /// + public ModificationVersion ToModificationVersion(LauncherContentVersionState version) + { + ArgumentNullException.ThrowIfNull(version); + + return new ModificationVersion + { + ModificationType = ToModificationType(version.ModificationType), + Name = version.Name ?? string.Empty, + Version = version.Version ?? string.Empty, + DependenceName = version.DependenceName ?? string.Empty, + Installed = version.Installed, + IsSelected = version.IsSelected, + ContentSourceKind = version.ContentSourceKind + }; + } + + /// + public LauncherContentVersionState ToVersionState( + ModificationVersion version, + LauncherContentType fallbackType) + { + ArgumentNullException.ThrowIfNull(version); + + return new LauncherContentVersionState + { + ModificationType = ToLauncherContentType(version.ModificationType, fallbackType), + Name = version.Name ?? string.Empty, + Version = version.Version ?? string.Empty, + DependenceName = version.DependenceName ?? string.Empty, + Installed = version.Installed, + IsSelected = version.IsSelected, + ContentSourceKind = version.ContentSourceKind + }; + } + + /// + public LauncherContentType GetFallbackContentType(ModificationType type) + { + return type switch + { + ModificationType.Addon => LauncherContentType.Addon, + ModificationType.Patch => LauncherContentType.Patch, + ModificationType.Advertising => LauncherContentType.Advertising, + _ => LauncherContentType.Mod + }; + } + + /// + /// Adds persisted versions to the in-memory catalog. + /// + /// The target catalog. + /// The persisted entries. + /// The content type to use when persisted state is ambiguous. + private void AddStoredVersions( + LauncherData launcherData, + IEnumerable entries, + LauncherContentType fallbackType) + { + foreach (LauncherContentEntryState entry in entries ?? Enumerable.Empty()) + { + GameModification? storedModification = null; + foreach (LauncherContentVersionState version in entry.ModificationVersions ?? + new List()) + { + ModificationVersion modificationVersion = ToModificationVersion(entry, version, fallbackType); + launcherData.AddOrUpdate(modificationVersion); + storedModification ??= FindStoredModification(launcherData, modificationVersion); + } + + if (storedModification != null) + { + storedModification.IsSelected = entry.IsSelected; + storedModification.NumberInList = entry.NumberInList; + } + } + } + + /// + /// Converts one persisted entry/version pair into a catalog version. + /// + /// The persisted entry. + /// The persisted version. + /// The content type to use when persisted state is ambiguous. + /// The catalog version. + private ModificationVersion ToModificationVersion( + LauncherContentEntryState entry, + LauncherContentVersionState version, + LauncherContentType fallbackType) + { + LauncherContentType contentType = ResolveContentType( + version.ModificationType, + entry.ModificationType, + fallbackType); + + return new ModificationVersion + { + ModificationType = ToModificationType(contentType), + Name = CoalesceStateText(version.Name, entry.Name), + Version = version.Version ?? string.Empty, + DependenceName = CoalesceStateText(version.DependenceName, entry.DependenceName), + Installed = version.Installed || entry.Installed, + IsSelected = entry.IsSelected && version.IsSelected, + ContentSourceKind = version.ContentSourceKind + }; + } + + /// + /// Converts catalog cards into persisted compact entries. + /// + /// The catalog cards. + /// The content type to use when catalog state is ambiguous. + /// The persisted entries. + private List ToEntryStates( + IEnumerable modifications, + LauncherContentType fallbackType) + { + var entries = new List(); + foreach (GameModification modification in modifications ?? Enumerable.Empty()) + { + var versions = modification.ModificationVersions + .Where(ShouldPersistVersion) + .Select(version => ToVersionState(version, fallbackType, modification.IsSelected)) + .ToList(); + + if (versions.Count == 0) + { + continue; + } + + entries.Add(new LauncherContentEntryState + { + ModificationType = ToLauncherContentType(modification.ModificationType, fallbackType), + Name = modification.Name ?? string.Empty, + DependenceName = modification.DependenceName ?? string.Empty, + Installed = modification.Installed, + IsSelected = modification.IsSelected, + NumberInList = modification.NumberInList, + ModificationVersions = versions + }); + } + + return entries; + } + + /// + /// Determines whether a catalog version should be persisted. + /// + /// The catalog version. + /// when the version has local state worth persisting. + private static bool ShouldPersistVersion(ModificationVersion version) + { + return version.Installed || version.IsSelected; + } + + /// + /// Converts one catalog version into persisted compact version state for a parent entry. + /// + /// The catalog version. + /// The content type to use when catalog state is ambiguous. + /// A value indicating whether the parent entry is selected. + /// The persisted compact version state. + private LauncherContentVersionState ToVersionState( + ModificationVersion version, + LauncherContentType fallbackType, + bool entryIsSelected) + { + LauncherContentVersionState versionState = ToVersionState(version, fallbackType); + versionState.IsSelected = entryIsSelected && versionState.IsSelected; + return versionState; + } + + /// + /// Finds the catalog card that contains a stored version. + /// + /// The catalog that was updated. + /// The version that was stored. + /// The matching catalog card, or when no card was stored. + private static GameModification? FindStoredModification( + LauncherData launcherData, + ModificationVersion version) + { + IEnumerable modifications = version.ModificationType switch + { + ModificationType.Addon => launcherData.Addons, + ModificationType.Patch => launcherData.Patches, + _ => launcherData.Modifications + }; + + return modifications.FirstOrDefault(modification => + String.Equals(modification.Name, version.Name, StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Returns a value or fallback when the value is missing. + /// + /// The preferred value. + /// The fallback value. + /// The preferred value when present; otherwise the fallback. + private static string CoalesceStateText(string value, string fallback) + { + return !String.IsNullOrWhiteSpace(value) ? value : fallback ?? string.Empty; + } + + /// + /// Resolves compact content type from version, entry, and fallback state. + /// + /// The version content type. + /// The entry content type. + /// The fallback content type. + /// The resolved content type. + private static LauncherContentType ResolveContentType( + LauncherContentType versionType, + LauncherContentType entryType, + LauncherContentType fallbackType) + { + if (versionType != LauncherContentType.Mod || fallbackType == LauncherContentType.Mod) + { + return versionType; + } + + return entryType != LauncherContentType.Mod ? entryType : fallbackType; + } + + /// + /// Converts compact content type to legacy modification type. + /// + /// The compact content type. + /// The legacy modification type. + private static ModificationType ToModificationType(LauncherContentType type) + { + return type switch + { + LauncherContentType.Addon => ModificationType.Addon, + LauncherContentType.Patch => ModificationType.Patch, + LauncherContentType.Advertising => ModificationType.Advertising, + _ => ModificationType.Mod + }; + } + + /// + /// Converts legacy modification type to compact content type. + /// + /// The legacy modification type. + /// The fallback content type. + /// The compact content type. + private static LauncherContentType ToLauncherContentType( + ModificationType type, + LauncherContentType fallbackType) + { + return type switch + { + ModificationType.Addon => LauncherContentType.Addon, + ModificationType.Patch => LauncherContentType.Patch, + ModificationType.Advertising => LauncherContentType.Advertising, + ModificationType.Mod => fallbackType, + _ => fallbackType + }; + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/LauncherLocalContentReconciler.cs b/GenLauncherGO.Infrastructure/Mods/Services/LauncherLocalContentReconciler.cs new file mode 100644 index 00000000..6565f882 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/LauncherLocalContentReconciler.cs @@ -0,0 +1,369 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Contracts; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Reconciles launcher catalog state with local content folders. +/// +internal sealed class LauncherLocalContentReconciler : ILauncherLocalContentReconciler +{ + /// + /// The local file-system content service. + /// + private readonly ILocalLauncherContentService _localContentService; + + /// + /// The mapper used for compact folder-state requests. + /// + private readonly ILauncherContentStateMapper _stateMapper; + + /// + /// The selection query service used to preserve legacy child-content cleanup behavior. + /// + private readonly LauncherContentSelectionService _selectionService; + + /// + /// The logger used for local catalog reconciliation diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The local file-system content service. + /// The mapper used for compact folder-state requests. + /// The selection query service used to preserve legacy child-content cleanup behavior. + /// The logger used for local catalog reconciliation diagnostics. + public LauncherLocalContentReconciler( + ILocalLauncherContentService localContentService, + ILauncherContentStateMapper stateMapper, + LauncherContentSelectionService selectionService, + ILogger logger) + { + _localContentService = localContentService ?? throw new ArgumentNullException(nameof(localContentService)); + _stateMapper = stateMapper ?? throw new ArgumentNullException(nameof(stateMapper)); + _selectionService = selectionService ?? throw new ArgumentNullException(nameof(selectionService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public void Reconcile( + LauncherData launcherData, + IReadOnlyCollection downloadedReposContent, + LauncherPaths paths, + LauncherContentLayout layout) + { + ArgumentNullException.ThrowIfNull(launcherData); + ArgumentNullException.ThrowIfNull(downloadedReposContent); + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(layout); + + IReadOnlyList installedVersions = + _localContentService.FindInstalledVersions(paths, layout); + + AddUnregisteredModifications(launcherData, installedVersions); + (int MarkedNotInstalledCount, int RemovedCount) changes = DeleteOutdatedModifications( + launcherData, + downloadedReposContent, + installedVersions, + paths, + layout); + _logger.LogInformation( + "Reconciled launcher catalog with local content folders. Local versions: {LocalVersionCount}; " + + "marked not installed: {MarkedNotInstalledCount}; " + + "removed stale catalog entries: {RemovedCatalogEntryCount}.", + installedVersions.Count, + changes.MarkedNotInstalledCount, + changes.RemovedCount); + } + + /// + public void DeleteVersion( + LauncherData launcherData, + ModificationVersion modificationVersion, + LauncherPaths paths, + LauncherContentLayout layout) + { + ArgumentNullException.ThrowIfNull(launcherData); + ArgumentNullException.ThrowIfNull(modificationVersion); + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(layout); + + if (modificationVersion.ModificationType == ModificationType.Advertising) + { + launcherData.Delete(modificationVersion); + return; + } + + _localContentService.DeleteVersion( + paths, + layout, + _stateMapper.ToVersionState( + modificationVersion, + _stateMapper.GetFallbackContentType(modificationVersion.ModificationType))); + } + + /// + public void DeleteContent( + LauncherData launcherData, + ModificationVersion modificationVersion, + LauncherPaths paths, + LauncherContentLayout layout) + { + ArgumentNullException.ThrowIfNull(launcherData); + ArgumentNullException.ThrowIfNull(modificationVersion); + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(layout); + + if (modificationVersion.ModificationType == ModificationType.Advertising) + { + launcherData.Delete(modificationVersion); + return; + } + + _localContentService.DeleteContent( + paths, + layout, + _stateMapper.ToVersionState( + modificationVersion, + _stateMapper.GetFallbackContentType(modificationVersion.ModificationType))); + } + + /// + /// Adds locally installed versions that are missing from the current catalog. + /// + /// The mutable launcher catalog. + /// The installed versions discovered from the local content folders. + private void AddUnregisteredModifications( + LauncherData launcherData, + IEnumerable installedVersions) + { + foreach (LauncherContentVersionState version in installedVersions) + { + launcherData.AddOrUpdate(_stateMapper.ToModificationVersion(version)); + } + } + + /// + /// Removes local-only catalog entries whose folders no longer contain files. + /// + /// The mutable launcher catalog. + /// Remote content versions already downloaded into the catalog. + /// The installed versions discovered from the local content folders. + /// The launcher paths used to inspect local content folders. + /// The content folder layout. + /// The number of remote entries marked not installed and local-only entries removed. + private (int MarkedNotInstalledCount, int RemovedCount) DeleteOutdatedModifications( + LauncherData launcherData, + IReadOnlyCollection downloadedReposContent, + IReadOnlyCollection installedVersions, + LauncherPaths paths, + LauncherContentLayout layout) + { + var installedVersionIds = + installedVersions.Select(CreateVersionIdentity).ToHashSet(); + int markedNotInstalledCount = 0; + int removedCount = 0; + + IEnumerable modVersions = _selectionService.GetAllModsVersionsList(launcherData) + .Where(mod => mod.ModificationType != ModificationType.Advertising); + + foreach (ModificationVersion modVersion in modVersions) + { + CountReconciliationResult(CheckContentExistence( + launcherData, + downloadedReposContent, + modVersion, + paths, + layout, + installedVersionIds), ref markedNotInstalledCount, ref removedCount); + + foreach (ModificationVersion addonVersion in _selectionService.GetAddonVersionsForModList( + launcherData, + modVersion.Name)) + { + CountReconciliationResult(CheckContentExistence( + launcherData, + downloadedReposContent, + addonVersion, + paths, + layout, + installedVersionIds), ref markedNotInstalledCount, ref removedCount); + } + + foreach (ModificationVersion patchVersion in _selectionService.GetPatchVersionsForModList( + launcherData, + modVersion.Name)) + { + CountReconciliationResult(CheckContentExistence( + launcherData, + downloadedReposContent, + patchVersion, + paths, + layout, + installedVersionIds), ref markedNotInstalledCount, ref removedCount); + } + } + + return (markedNotInstalledCount, removedCount); + } + + /// + /// Removes or marks a content version when the local folder no longer contains files. + /// + /// The mutable launcher catalog. + /// Remote content versions already downloaded into the catalog. + /// The content version to inspect. + /// The launcher paths used to inspect local content folders. + /// The content folder layout. + /// The installed version identities discovered from local content folders. + /// A tuple describing whether the catalog entry was changed. + private (bool MarkedNotInstalled, bool Removed) CheckContentExistence( + LauncherData launcherData, + IReadOnlyCollection downloadedReposContent, + ModificationVersion modificationVersion, + LauncherPaths paths, + LauncherContentLayout layout, + HashSet<(LauncherContentType Type, string Name, string Version, string DependenceName)> installedVersionIds) + { + LauncherContentType fallbackType = _stateMapper.GetFallbackContentType(modificationVersion.ModificationType); + if (installedVersionIds.Contains(CreateVersionIdentity(modificationVersion, fallbackType))) + { + return (false, false); + } + + LauncherContentVersionState versionState = _stateMapper.ToVersionState(modificationVersion, fallbackType); + if (_localContentService.VersionFolderExists(paths, layout, versionState)) + { + modificationVersion.Installed = true; + return (false, false); + } + + if (downloadedReposContent.Contains(modificationVersion)) + { + if (modificationVersion.Installed) + { + modificationVersion.Installed = false; + return (true, false); + } + } + else + { + launcherData.Delete(modificationVersion); + DeleteModificationImagesIfCardMissing(launcherData, modificationVersion, paths); + return (false, true); + } + + return (false, false); + } + + /// + /// Adds one reconciliation result to aggregate counters. + /// + /// The reconciliation result returned for one catalog version. + /// The aggregate count of versions marked not installed. + /// The aggregate count of removed catalog entries. + private static void CountReconciliationResult( + (bool MarkedNotInstalled, bool Removed) result, + ref int markedNotInstalledCount, + ref int removedCount) + { + if (result.MarkedNotInstalled) + { + markedNotInstalledCount++; + } + + if (result.Removed) + { + removedCount++; + } + } + + /// + /// Creates a version identity from local folder state. + /// + /// The local folder version state. + /// The version identity. + private static (LauncherContentType Type, string Name, string Version, string DependenceName) CreateVersionIdentity( + LauncherContentVersionState version) + { + return ( + version.ModificationType, + NormalizeIdentityText(version.Name), + NormalizeIdentityText(version.Version), + NormalizeIdentityText(version.DependenceName)); + } + + /// + /// Creates a version identity from a catalog version. + /// + /// The catalog version. + /// The content type used when the catalog version has ambiguous legacy type data. + /// The version identity. + private static (LauncherContentType Type, string Name, string Version, string DependenceName) CreateVersionIdentity( + ModificationVersion version, + LauncherContentType fallbackType) + { + return ( + ToLauncherContentType(version.ModificationType, fallbackType), + NormalizeIdentityText(version.Name), + NormalizeIdentityText(version.Version), + NormalizeIdentityText(version.DependenceName)); + } + + /// + /// Normalizes text for case-insensitive version identity comparison. + /// + /// The value to normalize. + /// The normalized identity text. + private static string NormalizeIdentityText(string value) + { + return (value ?? string.Empty).ToUpperInvariant(); + } + + /// + /// Converts legacy modification type to compact content type for identity comparison. + /// + /// The legacy modification type. + /// The fallback content type. + /// The compact content type. + private static LauncherContentType ToLauncherContentType( + ModificationType type, + LauncherContentType fallbackType) + { + return type switch + { + ModificationType.Addon => LauncherContentType.Addon, + ModificationType.Patch => LauncherContentType.Patch, + ModificationType.Advertising => LauncherContentType.Advertising, + ModificationType.Mod => fallbackType, + _ => fallbackType + }; + } + + /// + /// Deletes cached images for a catalog card that no longer exists. + /// + /// The mutable launcher catalog. + /// The removed modification version. + /// The launcher paths used to locate cached images. + private void DeleteModificationImagesIfCardMissing( + LauncherData launcherData, + ModificationVersion modificationVersion, + LauncherPaths paths) + { + _localContentService.DeleteImagesIfUnused( + paths, + _stateMapper.ToVersionState( + modificationVersion, + _stateMapper.GetFallbackContentType(modificationVersion.ModificationType)), + _stateMapper.ToLauncherContentState(launcherData)); + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/RemoteLauncherCatalogClient.cs b/GenLauncherGO.Infrastructure/Mods/Services/RemoteLauncherCatalogClient.cs new file mode 100644 index 00000000..11705b5a --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/RemoteLauncherCatalogClient.cs @@ -0,0 +1,277 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Remote; +using GenLauncherGO.Infrastructure.Mods.Contracts; +using GenLauncherGO.Infrastructure.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Support; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Reads legacy-compatible remote launcher catalog YAML documents. +/// +/// +/// The remote catalog schema is owned by a third-party backend. This client must continue using the legacy manifest +/// DTOs and field names unless a future change adds explicit dual-schema read support and backend compatibility tests. +/// +internal sealed class RemoteLauncherCatalogClient : IRemoteLauncherCatalogClient +{ + /// + /// The maximum number of remote manifest reads allowed at once during catalog refresh. + /// + private const int MaxConcurrentManifestReads = 6; + + /// + /// The remote YAML reader used for repository manifests. + /// + private readonly IRemoteYamlDocumentReader _yamlDocumentReader; + + /// + /// The logger used for partial remote catalog failures. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The remote YAML reader used for repository manifests. + /// The logger used for partial remote catalog failures. + public RemoteLauncherCatalogClient( + IRemoteYamlDocumentReader yamlDocumentReader, + ILogger logger) + { + _yamlDocumentReader = yamlDocumentReader ?? throw new ArgumentNullException(nameof(yamlDocumentReader)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public async Task ReadCatalogAsync(Uri manifestUri, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(manifestUri); + + ReposModsData? repositoryData = await _yamlDocumentReader.ReadYamlAsync( + manifestUri, + cancellationToken).ConfigureAwait(false); + return RemoteLauncherCatalogMapper.ToRemoteCatalog(repositoryData); + } + + /// + public IReadOnlyList GetModificationNames(RemoteLauncherCatalog catalog) + { + ArgumentNullException.ThrowIfNull(catalog); + + return catalog.Modifications + .Select(modification => modification.Name) + .ToList(); + } + + /// + public async Task> DownloadInstalledModDataAsync( + RemoteLauncherCatalog catalog, + IReadOnlyCollection installedModNames, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(catalog); + ArgumentNullException.ThrowIfNull(installedModNames); + + var downloadedModNames = new HashSet(installedModNames, StringComparer.OrdinalIgnoreCase); + var installedModData = catalog.Modifications + .Where(reference => String.IsNullOrEmpty(reference.Name) || downloadedModNames.Contains(reference.Name)) + .ToList(); + + using var semaphore = new SemaphoreSlim(MaxConcurrentManifestReads); + RemoteModificationManifest?[] results = await Task.WhenAll( + installedModData.Select(reference => DownloadModDataIfAvailableAsync( + reference, + semaphore, + cancellationToken))).ConfigureAwait(false); + + var mods = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (RemoteModificationManifest? result in results) + { + if (result is null) + { + continue; + } + + if (!mods.ContainsKey(result.Content.Name)) + { + mods.Add(result.Content.Name, result); + } + } + + return mods.Values.ToList(); + } + + /// + public async Task DownloadModDataByNameAsync( + RemoteLauncherCatalog catalog, + string name, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(catalog); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + RemoteCatalogModificationReference reference = catalog.Modifications + .First(data => string.Equals(data.Name, name, StringComparison.OrdinalIgnoreCase)); + + return await DownloadModDataAsync(reference, cancellationToken).ConfigureAwait(false); + } + + /// + public async Task> ReadChildManifestsAsync( + IEnumerable manifestUrls, + CancellationToken cancellationToken) + { + using var semaphore = new SemaphoreSlim(MaxConcurrentManifestReads); + RemoteContentManifest?[] manifests = await Task.WhenAll( + (manifestUrls ?? new List()).Select(url => ReadChildManifestIfAvailableAsync( + url, + semaphore, + cancellationToken))).ConfigureAwait(false); + + return manifests.Where(manifest => manifest != null).ToList()!; + } + + /// + /// Downloads one modification manifest while respecting the startup refresh concurrency limit. + /// + /// The parent manifest reference. + /// The concurrency gate. + /// The token used to cancel remote work. + /// The downloaded manifest pair, or when the manifest could not be read. + private async Task DownloadModDataIfAvailableAsync( + RemoteCatalogModificationReference reference, + SemaphoreSlim semaphore, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(reference); + + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + try + { + return await DownloadModDataAsync(reference, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning( + "Failed to download remote modification manifest for {ModificationName}: {FailureReason}.", + reference.Name, + exception.Message); + return null; + } + } + finally + { + semaphore.Release(); + } + } + + /// + /// Reads one child manifest while respecting the startup refresh concurrency limit. + /// + /// The child manifest URL. + /// The concurrency gate. + /// The token used to cancel remote work. + /// The child manifest, or when it could not be read. + private async Task ReadChildManifestIfAvailableAsync( + string url, + SemaphoreSlim semaphore, + CancellationToken cancellationToken) + { + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + try + { + return await ReadModificationYamlAsync(url, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning( + "Failed to read child modification manifest: {FailureReason}.", + exception.Message); + return null; + } + } + finally + { + semaphore.Release(); + } + } + + /// + public async Task DownloadAdvertisingInfoAsync( + string manifestUrl, + CancellationToken cancellationToken) + { + try + { + return await ReadModificationYamlAsync(manifestUrl, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning( + "Failed to download advertising manifest: {FailureReason}.", + exception.Message); + } + + return null; + } + + /// + /// Downloads one modification manifest. + /// + /// The parent manifest reference. + /// The token used to cancel remote work. + /// The downloaded modification metadata and parent entry. + private async Task DownloadModDataAsync( + RemoteCatalogModificationReference reference, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(reference); + + RemoteContentManifest modification = await ReadModificationYamlAsync( + reference.ManifestUrl, + cancellationToken).ConfigureAwait(false); + return new RemoteModificationManifest( + modification, + reference.PatchManifestUrls, + reference.AddonManifestUrls); + } + + /// + /// Reads one modification YAML document. + /// + /// The document URL. + /// The token used to cancel remote work. + /// The normalized modification metadata. + private async Task ReadModificationYamlAsync( + string documentUrl, + CancellationToken cancellationToken) + { + ModificationReposVersion manifest = await _yamlDocumentReader.ReadYamlAsync( + new Uri(documentUrl, UriKind.Absolute), + cancellationToken).ConfigureAwait(false); + return RemoteLauncherCatalogMapper.ToRemoteContentManifest(manifest); + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/YamlLauncherContentStateStore.cs b/GenLauncherGO.Infrastructure/Mods/Services/YamlLauncherContentStateStore.cs new file mode 100644 index 00000000..08ba58bd --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/YamlLauncherContentStateStore.cs @@ -0,0 +1,40 @@ +using System; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Persistence.Services; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Stores launcher content state in a YAML-backed document. +/// +public sealed class YamlLauncherContentStateStore : ILauncherContentStateStore +{ + /// + /// The YAML document store used for content-state persistence. + /// + private readonly IYamlDocumentStore _documentStore; + + /// + /// Initializes a new instance of the class. + /// + /// The YAML document store used for content-state persistence. + public YamlLauncherContentStateStore(IYamlDocumentStore documentStore) + { + _documentStore = documentStore ?? throw new ArgumentNullException(nameof(documentStore)); + } + + /// + public LauncherContentState Load() + { + return _documentStore.Load(new LauncherContentState()); + } + + /// + public void Save(LauncherContentState state) + { + ArgumentNullException.ThrowIfNull(state); + + _documentStore.Save(state); + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Support/RemoteLauncherCatalogMapper.cs b/GenLauncherGO.Infrastructure/Mods/Support/RemoteLauncherCatalogMapper.cs new file mode 100644 index 00000000..bd54ebf9 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Support/RemoteLauncherCatalogMapper.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Support; + +/// +/// Maps third-party backend manifest DTOs to normalized remote catalog models. +/// +internal static class RemoteLauncherCatalogMapper +{ + /// + /// Maps a backend repository manifest to a normalized remote catalog. + /// + /// The backend repository manifest. + /// The normalized remote catalog. + public static RemoteLauncherCatalog ToRemoteCatalog(ReposModsData? repositoryData) + { + if (repositoryData is null) + { + return RemoteLauncherCatalog.Empty; + } + + return new RemoteLauncherCatalog( + ToAdvertisingReferences(repositoryData.AdvData), + ToModificationReferences(repositoryData.modDatas), + ToStringList(repositoryData.originalGameAddons), + ToStringList(repositoryData.originalGamePatches), + repositoryData.LauncherVersion); + } + + /// + /// Maps a backend content manifest to normalized remote content metadata. + /// + /// The backend content manifest. + /// The normalized remote content manifest. + public static RemoteContentManifest ToRemoteContentManifest(ModificationReposVersion? manifest) + { + if (manifest is null) + { + return new RemoteContentManifest(); + } + + return new RemoteContentManifest + { + ModificationType = manifest.ModificationType, + Name = manifest.Name ?? string.Empty, + Version = manifest.Version ?? string.Empty, + SimpleDownloadLink = manifest.SimpleDownloadLink ?? string.Empty, + ImageSourceLink = manifest.UIImageSourceLink ?? string.Empty, + DiscordLink = manifest.DiscordLink ?? string.Empty, + ModDbLink = manifest.ModDBLink ?? string.Empty, + NewsLink = manifest.NewsLink ?? string.Empty, + DependenceName = manifest.DependenceName ?? string.Empty, + S3HostLink = manifest.S3HostLink ?? string.Empty, + S3BucketName = manifest.S3BucketName ?? string.Empty, + S3FolderName = manifest.S3FolderName ?? string.Empty, + S3HostPublicKey = manifest.S3HostPublicKey ?? string.Empty, + S3HostSecretKey = manifest.S3HostSecretKey ?? string.Empty, + NetworkInfo = manifest.NetworkInfo ?? string.Empty, + Deprecated = manifest.Deprecated, + SupportLink = manifest.SupportLink ?? string.Empty, + ColorsInformation = manifest.ColorsInformation, + ContentSourceKind = manifest.ContentSourceKind + }; + } + + /// + /// Maps normalized remote content metadata into launcher catalog version state. + /// + /// The normalized remote content manifest. + /// The launcher catalog version state. + public static ModificationVersion ToModificationVersion(RemoteContentManifest manifest) + { + ArgumentNullException.ThrowIfNull(manifest); + + return new ModificationVersion + { + ModificationType = manifest.ModificationType, + Name = manifest.Name, + Version = manifest.Version, + SimpleDownloadLink = manifest.SimpleDownloadLink, + UIImageSourceLink = manifest.ImageSourceLink, + DiscordLink = manifest.DiscordLink, + ModDBLink = manifest.ModDbLink, + NewsLink = manifest.NewsLink, + DependenceName = manifest.DependenceName, + S3HostLink = manifest.S3HostLink, + S3BucketName = manifest.S3BucketName, + S3FolderName = manifest.S3FolderName, + S3HostPublicKey = manifest.S3HostPublicKey, + S3HostSecretKey = manifest.S3HostSecretKey, + NetworkInfo = manifest.NetworkInfo, + Deprecated = manifest.Deprecated, + SupportLink = manifest.SupportLink, + ColorsInformation = manifest.ColorsInformation, + ContentSourceKind = ModificationReposVersion.ResolveContentSourceKind( + manifest.S3HostLink, + manifest.S3BucketName, + manifest.S3FolderName, + manifest.SimpleDownloadLink, + manifest.ContentSourceKind) + }; + } + + /// + /// Maps backend modification references to normalized references. + /// + /// The backend modification references. + /// The normalized modification references. + private static IReadOnlyList ToModificationReferences( + IEnumerable? modificationReferences) + { + return (modificationReferences ?? Enumerable.Empty()) + .Select(reference => new RemoteCatalogModificationReference( + reference.ModName, + reference.ModLink, + ToStringList(reference.ModPatches), + ToStringList(reference.ModAddons))) + .ToList(); + } + + /// + /// Maps backend advertising references to normalized references. + /// + /// The backend advertising references. + /// The normalized advertising references. + private static IReadOnlyList ToAdvertisingReferences( + IEnumerable? advertisingReferences) + { + return (advertisingReferences ?? Enumerable.Empty()) + .Select(reference => new RemoteAdvertisingReference( + reference.ModName, + reference.ModLink, + ToStringList(reference.ImagesData))) + .ToList(); + } + + /// + /// Copies a string sequence into a read-only list while removing null entries. + /// + /// The source values. + /// The copied string values. + private static IReadOnlyList ToStringList(IEnumerable? values) + { + return (values ?? Enumerable.Empty()) + .Where(value => value != null) + .ToList()!; + } + +} diff --git a/GenLauncherGO.Infrastructure/Persistence/Options/YamlDocumentStoreOptions.cs b/GenLauncherGO.Infrastructure/Persistence/Options/YamlDocumentStoreOptions.cs new file mode 100644 index 00000000..504aa842 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Persistence/Options/YamlDocumentStoreOptions.cs @@ -0,0 +1,28 @@ +using System; + +namespace GenLauncherGO.Infrastructure.Persistence.Options; + +/// +/// Describes where a YAML-backed document store persists its document. +/// +public sealed class YamlDocumentStoreOptions +{ + /// + /// Initializes a new instance of the class. + /// + /// The YAML document file path. + /// + /// Thrown when is , empty, or whitespace. + /// + public YamlDocumentStoreOptions(string documentFilePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(documentFilePath); + + DocumentFilePath = documentFilePath; + } + + /// + /// Gets the YAML document file path. + /// + public string DocumentFilePath { get; } +} diff --git a/GenLauncherGO.Infrastructure/Persistence/Services/IYamlDocumentStore.cs b/GenLauncherGO.Infrastructure/Persistence/Services/IYamlDocumentStore.cs new file mode 100644 index 00000000..d9918a13 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Persistence/Services/IYamlDocumentStore.cs @@ -0,0 +1,22 @@ +namespace GenLauncherGO.Infrastructure.Persistence.Services; + +/// +/// Loads and saves one YAML-backed document. +/// +/// The document type. +public interface IYamlDocumentStore + where TDocument : class +{ + /// + /// Loads the document from disk. + /// + /// The document returned when the persisted document is missing or unreadable. + /// The loaded document, or . + TDocument Load(TDocument defaultDocument); + + /// + /// Saves the document to disk. + /// + /// The document to save. + void Save(TDocument document); +} diff --git a/GenLauncherGO.Infrastructure/Persistence/Services/YamlDocumentStore.cs b/GenLauncherGO.Infrastructure/Persistence/Services/YamlDocumentStore.cs new file mode 100644 index 00000000..f819a9cb --- /dev/null +++ b/GenLauncherGO.Infrastructure/Persistence/Services/YamlDocumentStore.cs @@ -0,0 +1,95 @@ +using System; +using System.IO; +using GenLauncherGO.Infrastructure.Persistence.Options; +using Microsoft.Extensions.Logging; +using YamlDotNet.Serialization; + +namespace GenLauncherGO.Infrastructure.Persistence.Services; + +/// +/// Loads and saves one YAML-backed document on the local file system. +/// +/// The document type. +public sealed class YamlDocumentStore : IYamlDocumentStore + where TDocument : class +{ + /// + /// The file-system path options. + /// + private readonly YamlDocumentStoreOptions _options; + + /// + /// The logger used for persistence diagnostics. + /// + private readonly ILogger> _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The file-system path options. + /// The logger used for persistence diagnostics. + public YamlDocumentStore( + YamlDocumentStoreOptions options, + ILogger> logger) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public TDocument Load(TDocument defaultDocument) + { + ArgumentNullException.ThrowIfNull(defaultDocument); + + if (!File.Exists(_options.DocumentFilePath)) + { + return defaultDocument; + } + + try + { + IDeserializer deserializer = new DeserializerBuilder() + .IgnoreUnmatchedProperties() + .Build(); + + using TextReader reader = File.OpenText(_options.DocumentFilePath); + return deserializer.Deserialize(reader) ?? defaultDocument; + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to load {DocumentType} from {DocumentFileName}.", + typeof(TDocument).Name, + Path.GetFileName(_options.DocumentFilePath)); + return defaultDocument; + } + } + + /// + public void Save(TDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + try + { + string? directory = Path.GetDirectoryName(_options.DocumentFilePath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + ISerializer serializer = new Serializer(); + using TextWriter writer = File.CreateText(_options.DocumentFilePath); + serializer.Serialize(writer, document); + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to save {DocumentType} to {DocumentFileName}.", + typeof(TDocument).Name, + Path.GetFileName(_options.DocumentFilePath)); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Properties/AssemblyInfo.cs b/GenLauncherGO.Infrastructure/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..3e82382a --- /dev/null +++ b/GenLauncherGO.Infrastructure/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("GenLauncherGO.Tests")] diff --git a/GenLauncherGO.Infrastructure/Remote/HttpRemoteAssetDownloader.cs b/GenLauncherGO.Infrastructure/Remote/HttpRemoteAssetDownloader.cs new file mode 100644 index 00000000..94b124e5 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Remote/HttpRemoteAssetDownloader.cs @@ -0,0 +1,81 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Remote; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Remote; + +/// +/// Downloads remote assets to disk through the resumable downloader. +/// +public sealed class HttpRemoteAssetDownloader : IRemoteAssetDownloader +{ + private readonly IResumableFileDownloader _fileDownloader; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The file downloader used for transfers. + /// The logger used for asset download diagnostics. + public HttpRemoteAssetDownloader( + IResumableFileDownloader fileDownloader, + ILogger logger) + { + _fileDownloader = fileDownloader ?? throw new ArgumentNullException(nameof(fileDownloader)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Downloads a remote asset to a temporary file and atomically moves it into place when the final file is missing. + /// + /// The remote asset URI. + /// The destination asset path. + /// A token that cancels the download. + /// A task that completes after the asset exists locally. + public async Task DownloadIfMissingAsync( + Uri sourceUri, + string destinationFilePath, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(sourceUri); + ArgumentException.ThrowIfNullOrWhiteSpace(destinationFilePath); + + if (File.Exists(destinationFilePath)) + { + return; + } + + Directory.CreateDirectory(Path.GetDirectoryName(destinationFilePath) ?? "."); + string temporaryFilePath = destinationFilePath + ".download"; + if (File.Exists(temporaryFilePath)) + { + File.Delete(temporaryFilePath); + _logger.LogInformation( + "Deleted stale remote asset download file {FileName}.", + Path.GetFileName(temporaryFilePath)); + } + + await _fileDownloader.DownloadFileAsync( + new DownloadFileRequest(sourceUri, temporaryFilePath, Resume: false), + null, + cancellationToken).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + if (File.Exists(destinationFilePath)) + { + File.Delete(temporaryFilePath); + return; + } + + File.Move(temporaryFilePath, destinationFilePath); + _logger.LogInformation( + "Downloaded remote asset {FileName} from {Host}.", + Path.GetFileName(destinationFilePath), + sourceUri.Host); + } +} diff --git a/GenLauncherGO.Infrastructure/Remote/HttpRemoteConnectionProbe.cs b/GenLauncherGO.Infrastructure/Remote/HttpRemoteConnectionProbe.cs new file mode 100644 index 00000000..26f76297 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Remote/HttpRemoteConnectionProbe.cs @@ -0,0 +1,90 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Remote; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Remote; + +/// +/// Checks remote HTTP endpoint connectivity. +/// +public sealed class HttpRemoteConnectionProbe : IRemoteConnectionProbe +{ + private static readonly HttpClient _sharedHttpClient = + SharedHttpClientFactory.Create(TimeSpan.FromSeconds(30)); + + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP client used for requests. + /// The logger used for probe diagnostics. + public HttpRemoteConnectionProbe( + ILogger logger, + HttpClient? httpClient = null) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _httpClient = httpClient ?? _sharedHttpClient; + } + + /// + /// Checks whether the remote endpoint can be reached through HEAD or GET without downloading the response body. + /// + /// The remote endpoint to probe. + /// A token that cancels probe requests. + /// when the endpoint returns a successful status code. + public async Task CanConnectAsync(Uri endpointUri, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(endpointUri); + + try + { + return await SendProbeAsync(endpointUri, HttpMethod.Head, cancellationToken).ConfigureAwait(false) || + await SendProbeAsync(endpointUri, HttpMethod.Get, cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException ex) + { + _logger.LogWarning( + ex, + "Remote connection probe failed for {Scheme}://{Host}.", + endpointUri.Scheme, + endpointUri.Host); + return false; + } + catch (TaskCanceledException ex) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogWarning( + ex, + "Remote connection probe timed out for {Scheme}://{Host}.", + endpointUri.Scheme, + endpointUri.Host); + return false; + } + } + + private async Task SendProbeAsync( + Uri endpointUri, + HttpMethod httpMethod, + CancellationToken cancellationToken) + { + using HttpRequestMessage request = new(httpMethod, endpointUri); + using HttpResponseMessage response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + + if (httpMethod == HttpMethod.Head && + (response.StatusCode == HttpStatusCode.MethodNotAllowed || + response.StatusCode == HttpStatusCode.NotImplemented)) + { + return false; + } + + return response.IsSuccessStatusCode; + } +} diff --git a/GenLauncherGO.Infrastructure/Remote/HttpRemoteYamlDocumentReader.cs b/GenLauncherGO.Infrastructure/Remote/HttpRemoteYamlDocumentReader.cs new file mode 100644 index 00000000..b745f9b2 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Remote/HttpRemoteYamlDocumentReader.cs @@ -0,0 +1,76 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Remote; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using YamlDotNet.Serialization; + +namespace GenLauncherGO.Infrastructure.Remote; + +/// +/// Reads YAML documents over HTTP. +/// +public sealed class HttpRemoteYamlDocumentReader : IRemoteYamlDocumentReader +{ + private static readonly HttpClient _sharedHttpClient = + SharedHttpClientFactory.Create(TimeSpan.FromSeconds(60)); + + private readonly IDeserializer _deserializer; + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP client used for requests. + /// The logger used for YAML read diagnostics. + public HttpRemoteYamlDocumentReader( + HttpClient? httpClient = null, + ILogger? logger = null) + { + _httpClient = httpClient ?? _sharedHttpClient; + _logger = logger ?? NullLogger.Instance; + _deserializer = new DeserializerBuilder() + .IgnoreUnmatchedProperties() + .Build(); + } + + /// + public async Task ReadYamlAsync(Uri documentUri, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(documentUri); + + try + { + using HttpRequestMessage request = new(HttpMethod.Get, documentUri); + using HttpResponseMessage response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + + response.EnsureSuccessStatusCode(); + + await using Stream contentStream = await response.Content.ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + using StreamReader reader = new(contentStream); + + return _deserializer.Deserialize(reader); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogDebug( + ex, + "Failed to read remote YAML document from {Scheme}://{Host}.", + documentUri.Scheme, + documentUri.Host); + throw; + } + } +} diff --git a/GenLauncherGO.Infrastructure/Remote/SharedHttpClientFactory.cs b/GenLauncherGO.Infrastructure/Remote/SharedHttpClientFactory.cs new file mode 100644 index 00000000..768714b1 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Remote/SharedHttpClientFactory.cs @@ -0,0 +1,39 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; + +namespace GenLauncherGO.Infrastructure.Remote; + +/// +/// Creates shared HTTP clients with desktop launcher defaults. +/// +internal static class SharedHttpClientFactory +{ + /// + /// Creates an HTTP client with pooled connections, no automatic decompression, and a GenLauncherGO user agent. + /// + /// The overall request timeout for the created client. + /// A configured HTTP client. + public static HttpClient Create(TimeSpan timeout) + { + SocketsHttpHandler handler = new() + { + AutomaticDecompression = DecompressionMethods.None, + ConnectTimeout = TimeSpan.FromSeconds(30), + MaxConnectionsPerServer = 16, + PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2), + PooledConnectionLifetime = TimeSpan.FromMinutes(15), + }; + + HttpClient httpClient = new(handler) + { + Timeout = timeout, + }; + + httpClient.DefaultRequestHeaders.UserAgent.Add( + new ProductInfoHeaderValue("GenLauncherGO", "1")); + + return httpClient; + } +} diff --git a/GenLauncherGO.Infrastructure/Settings/Composition/SettingsInfrastructureServiceCollectionExtensions.cs b/GenLauncherGO.Infrastructure/Settings/Composition/SettingsInfrastructureServiceCollectionExtensions.cs new file mode 100644 index 00000000..1e797ed3 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Settings/Composition/SettingsInfrastructureServiceCollectionExtensions.cs @@ -0,0 +1,43 @@ +using System; +using GenLauncherGO.Core.Settings.Contracts; +using GenLauncherGO.Infrastructure.Settings.Options; +using GenLauncherGO.Infrastructure.Settings.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Infrastructure.Settings.Composition; + +/// +/// Provides dependency-injection registrations for launcher settings infrastructure. +/// +public static class SettingsInfrastructureServiceCollectionExtensions +{ + /// + /// Registers infrastructure services used by the launcher settings feature. + /// + /// The service collection used by the application composition root. + /// The YAML file path where launcher preferences are persisted. + /// The launcher logs directory path opened from settings. + /// The same service collection so additional registrations can be chained. + /// + /// Thrown when is . + /// + /// + /// Thrown when or is + /// , empty, or whitespace. + /// + public static IServiceCollection AddGenLauncherGoSettingsInfrastructure( + this IServiceCollection services, + string preferencesFilePath, + string logsDirectory) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(preferencesFilePath); + ArgumentException.ThrowIfNullOrWhiteSpace(logsDirectory); + + services.AddSingleton(new LauncherPreferencesStoreOptions(preferencesFilePath)); + services.AddSingleton(new LauncherSettingsLinkOptions(logsDirectory)); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/GenLauncherGO.Infrastructure/Settings/Options/LauncherPreferencesStoreOptions.cs b/GenLauncherGO.Infrastructure/Settings/Options/LauncherPreferencesStoreOptions.cs new file mode 100644 index 00000000..3937a295 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Settings/Options/LauncherPreferencesStoreOptions.cs @@ -0,0 +1,28 @@ +using System; + +namespace GenLauncherGO.Infrastructure.Settings.Options; + +/// +/// Describes the file-system location used to persist launcher preferences. +/// +public sealed class LauncherPreferencesStoreOptions +{ + /// + /// Initializes a new instance of the class. + /// + /// The YAML file path where launcher preferences are persisted. + /// + /// Thrown when is , empty, or whitespace. + /// + public LauncherPreferencesStoreOptions(string preferencesFilePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(preferencesFilePath); + + PreferencesFilePath = preferencesFilePath; + } + + /// + /// Gets the YAML file path where launcher preferences are persisted. + /// + public string PreferencesFilePath { get; } +} diff --git a/GenLauncherGO.Infrastructure/Settings/Options/LauncherSettingsLinkOptions.cs b/GenLauncherGO.Infrastructure/Settings/Options/LauncherSettingsLinkOptions.cs new file mode 100644 index 00000000..e21aa3d7 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Settings/Options/LauncherSettingsLinkOptions.cs @@ -0,0 +1,28 @@ +using System; + +namespace GenLauncherGO.Infrastructure.Settings.Options; + +/// +/// Describes file-system targets used by launcher settings links. +/// +public sealed class LauncherSettingsLinkOptions +{ + /// + /// Initializes a new instance of the class. + /// + /// The launcher logs directory path. + /// + /// Thrown when is , empty, or whitespace. + /// + public LauncherSettingsLinkOptions(string logsDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(logsDirectory); + + LogsDirectory = logsDirectory; + } + + /// + /// Gets the launcher logs directory path. + /// + public string LogsDirectory { get; } +} diff --git a/GenLauncherGO.Infrastructure/Settings/Services/PreferencesService.cs b/GenLauncherGO.Infrastructure/Settings/Services/PreferencesService.cs new file mode 100644 index 00000000..1ece35f7 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Settings/Services/PreferencesService.cs @@ -0,0 +1,140 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Settings.Contracts; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Infrastructure.Settings.Options; +using Microsoft.Extensions.Logging; +using YamlDotNet.Serialization; + +namespace GenLauncherGO.Infrastructure.Settings.Services; + +/// +/// Persists launcher preferences as a standalone YAML document. +/// +public sealed class PreferencesService : ILauncherPreferencesService +{ + /// + /// The preferences store options. + /// + private readonly LauncherPreferencesStoreOptions _options; + + /// + /// The logger used to record preference persistence failures. + /// + private readonly ILogger _logger; + + /// + /// The current in-memory preferences. + /// + private LauncherPreferences _current; + + /// + /// Initializes a new instance of the class. + /// + /// The preferences store options. + /// The logger used to record preference persistence failures. + public PreferencesService( + LauncherPreferencesStoreOptions options, + ILogger logger) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _current = LoadPreferences(); + } + + /// + public event EventHandler? PreferencesChanged; + + /// + public LauncherPreferences Current => _current; + + /// + public void Update(LauncherPreferences preferences) + { + ArgumentNullException.ThrowIfNull(preferences); + + LauncherPreferences normalizedPreferences = Normalize(preferences); + if (normalizedPreferences == _current) + { + return; + } + + _current = normalizedPreferences; + SavePreferences(normalizedPreferences); + PreferencesChanged?.Invoke(this, new LauncherPreferencesChangedEventArgs(_current)); + } + + /// + /// Reads launcher preferences from the configured YAML file. + /// + /// The loaded preferences, or defaults when the file is missing or unreadable. + private LauncherPreferences LoadPreferences() + { + if (!File.Exists(_options.PreferencesFilePath)) + { + return new LauncherPreferences(); + } + + try + { + IDeserializer deserializer = new DeserializerBuilder() + .IgnoreUnmatchedProperties() + .Build(); + + using TextReader reader = File.OpenText(_options.PreferencesFilePath); + LauncherPreferences? preferences = deserializer.Deserialize(reader); + return Normalize(preferences ?? new LauncherPreferences()); + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to read launcher preferences from {PreferencesFilePath}. Defaults will be used.", + _options.PreferencesFilePath); + return new LauncherPreferences(); + } + } + + /// + /// Writes launcher preferences to the configured YAML file. + /// + /// The preferences to write. + private void SavePreferences(LauncherPreferences preferences) + { + try + { + string? directory = Path.GetDirectoryName(_options.PreferencesFilePath); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + ISerializer serializer = new Serializer(); + using TextWriter writer = File.CreateText(_options.PreferencesFilePath); + serializer.Serialize(writer, preferences); + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to save launcher preferences to {PreferencesFilePath}.", + _options.PreferencesFilePath); + } + } + + /// + /// Normalizes nullable legacy-compatible string values to empty strings. + /// + /// The preferences to normalize. + /// The normalized preferences. + private static LauncherPreferences Normalize(LauncherPreferences preferences) + { + return preferences with + { + SelectedGameClient = preferences.SelectedGameClient ?? string.Empty, + SelectedWorldBuilder = preferences.SelectedWorldBuilder ?? string.Empty, + GameArguments = preferences.GameArguments ?? string.Empty, + WorldBuilderArguments = preferences.WorldBuilderArguments ?? string.Empty + }; + } +} diff --git a/GenLauncherGO.Infrastructure/Settings/Services/ProcessLauncherSettingsLinkService.cs b/GenLauncherGO.Infrastructure/Settings/Services/ProcessLauncherSettingsLinkService.cs new file mode 100644 index 00000000..4a0ed038 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Settings/Services/ProcessLauncherSettingsLinkService.cs @@ -0,0 +1,139 @@ +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using GenLauncherGO.Core.Settings.Contracts; +using GenLauncherGO.Infrastructure.Settings.Options; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Settings.Services; + +/// +/// Opens launcher settings links through the Windows shell. +/// +public sealed class ProcessLauncherSettingsLinkService : ILauncherSettingsLinkService +{ + /// + /// The Generals Online Discord server URL. + /// + private const string GeneralsOnlineDiscordUrl = "https://discord.playgenerals.online"; + + /// + /// The GenLauncherGO GitHub repository URL. + /// + private const string GitHubRepositoryUrl = "https://github.com/x64-dev/GenLauncher_GO"; + + /// + /// The donation URL for the original GenLauncher author. + /// + private const string OriginalAuthorDonationUrl = + "https://boosty.to/genlauncher/single-payment/donation/157147?share=target_link"; + + /// + /// The file-system link options. + /// + private readonly LauncherSettingsLinkOptions _options; + + /// + /// The logger used to record shell launch failures. + /// + private readonly ILogger _logger; + + /// + /// Opens a shell target with the host operating system. + /// + private readonly Action _openShellTarget; + + /// + /// Initializes a new instance of the class. + /// + /// The file-system link options. + /// The logger used to record shell launch failures. + public ProcessLauncherSettingsLinkService( + LauncherSettingsLinkOptions options, + ILogger logger) + : this(options, logger, OpenShellTarget) + { + } + + /// + /// Initializes a new instance of the class with a shell opener. + /// + /// The file-system link options. + /// The logger used to record shell launch failures. + /// Opens a shell target with the host operating system. + internal ProcessLauncherSettingsLinkService( + LauncherSettingsLinkOptions options, + ILogger logger, + Action openShellTarget) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _openShellTarget = openShellTarget ?? throw new ArgumentNullException(nameof(openShellTarget)); + } + + /// + public bool TryOpenGeneralsOnlineDiscordLink() + { + return TryOpenShellTarget(GeneralsOnlineDiscordUrl, "Generals Online Discord"); + } + + /// + public bool TryOpenLogsDirectory() + { + try + { + Directory.CreateDirectory(_options.LogsDirectory); + } + catch (Exception exception) + when (exception is IOException or UnauthorizedAccessException) + { + _logger.LogWarning(exception, "Failed to prepare launcher logs directory before opening it."); + return false; + } + + return TryOpenShellTarget(_options.LogsDirectory, "launcher logs directory"); + } + + /// + public bool TryOpenGitHubRepository() + { + return TryOpenShellTarget(GitHubRepositoryUrl, "GitHub repository"); + } + + /// + public bool TryOpenOriginalAuthorDonationLink() + { + return TryOpenShellTarget(OriginalAuthorDonationUrl, "original author donation link"); + } + + /// + /// Opens a shell target through Windows. + /// + /// The URL or local path to open. + /// A non-sensitive display name used in logs. + /// when the shell accepted the target; otherwise, . + private bool TryOpenShellTarget(string target, string diagnosticName) + { + try + { + _openShellTarget(target); + return true; + } + catch (Exception exception) + when (exception is Win32Exception or InvalidOperationException) + { + _logger.LogWarning(exception, "Failed to open launcher settings target {TargetName}.", diagnosticName); + return false; + } + } + + /// + /// Opens a target through the Windows shell. + /// + /// The URL or local path to open. + private static void OpenShellTarget(string target) + { + Process.Start(new ProcessStartInfo(target) { UseShellExecute = true }); + } +} diff --git a/GenLauncherGO.Infrastructure/Shell/Composition/ShellServiceCollectionExtensions.cs b/GenLauncherGO.Infrastructure/Shell/Composition/ShellServiceCollectionExtensions.cs new file mode 100644 index 00000000..08794cd6 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Shell/Composition/ShellServiceCollectionExtensions.cs @@ -0,0 +1,25 @@ +using System; +using GenLauncherGO.Core.Shell.Contracts; +using GenLauncherGO.Infrastructure.Shell.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Infrastructure.Shell.Composition; + +/// +/// Provides dependency-injection registrations for operating system shell services. +/// +public static class ShellServiceCollectionExtensions +{ + /// + /// Registers infrastructure services used to open external launcher targets. + /// + /// The service collection used by the application composition root. + /// The same service collection so additional registrations can be chained. + public static IServiceCollection AddGenLauncherGoShell(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddSingleton(); + return services; + } +} diff --git a/GenLauncherGO.Infrastructure/Shell/Services/WindowsLauncherShellService.cs b/GenLauncherGO.Infrastructure/Shell/Services/WindowsLauncherShellService.cs new file mode 100644 index 00000000..2b3896a9 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Shell/Services/WindowsLauncherShellService.cs @@ -0,0 +1,175 @@ +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.Shell.Contracts; +using GenLauncherGO.Core.Shell.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Shell.Services; + +/// +/// Opens external targets through the Windows shell. +/// +public sealed class WindowsLauncherShellService : ILauncherShellService +{ + /// + /// Logs diagnostics for shell-open side effects. + /// + private readonly ILogger _logger; + + /// + /// Opens a shell target with the host operating system. + /// + private readonly Action _openShellTarget; + + /// + /// Initializes a new instance of the class. + /// + /// The logger used for shell-open diagnostics. + public WindowsLauncherShellService(ILogger? logger = null) + : this(logger, OpenShellTarget) + { + } + + /// + /// Initializes a new instance of the class with a shell opener. + /// + /// The logger used for shell-open diagnostics. + /// Opens a shell target with the host operating system. + internal WindowsLauncherShellService( + ILogger? logger, + Action openShellTarget) + { + _logger = logger ?? NullLogger.Instance; + _openShellTarget = openShellTarget ?? throw new ArgumentNullException(nameof(openShellTarget)); + } + + /// + public ShellOpenResult OpenUri(string uri) + { + if (string.IsNullOrWhiteSpace(uri)) + { + return ShellOpenResult.Failure( + ShellOpenFailureKind.InvalidTarget, + "URI", + "The URI is empty."); + } + + if (!Uri.TryCreate(uri, UriKind.Absolute, out Uri? parsedUri)) + { + return ShellOpenResult.Failure( + ShellOpenFailureKind.InvalidTarget, + uri, + "The URI is not absolute."); + } + + if (!string.Equals(parsedUri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + && !string.Equals(parsedUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + return ShellOpenResult.Failure( + ShellOpenFailureKind.InvalidTarget, + uri, + string.Format(CultureInfo.InvariantCulture, "Unsupported URI scheme '{0}'.", parsedUri.Scheme)); + } + + return OpenShellTarget(parsedUri.AbsoluteUri, GetUriLogTarget(parsedUri)); + } + + /// + public ShellOpenResult OpenFolder(string folderPath, bool requireFiles = false) + { + if (string.IsNullOrWhiteSpace(folderPath)) + { + return ShellOpenResult.Failure( + ShellOpenFailureKind.InvalidTarget, + "Folder", + "The folder path is empty."); + } + + string fullPath; + try + { + fullPath = Path.GetFullPath(folderPath); + } + catch (Exception exception) when (exception is ArgumentException or NotSupportedException + or PathTooLongException) + { + return ShellOpenResult.Failure( + ShellOpenFailureKind.InvalidTarget, + folderPath, + exception.Message); + } + + if (!Directory.Exists(fullPath)) + { + return ShellOpenResult.Failure( + ShellOpenFailureKind.MissingTarget, + fullPath, + "The folder does not exist."); + } + + if (requireFiles && !Directory.EnumerateFiles(fullPath).Any()) + { + return ShellOpenResult.Failure( + ShellOpenFailureKind.MissingTarget, + fullPath, + "The folder does not contain files."); + } + + return OpenShellTarget(fullPath, Path.GetFileName(fullPath)); + } + + /// + /// Starts the Windows shell for a normalized target. + /// + /// The target to pass to the Windows shell. + /// A sanitized target label for logs. + /// The result of opening the target. + private ShellOpenResult OpenShellTarget(string target, string logTarget) + { + try + { + _openShellTarget(target); + return ShellOpenResult.Success(target); + } + catch (Exception exception) when (exception is Win32Exception or InvalidOperationException or IOException) + { + _logger.LogWarning( + exception, + "Could not open shell target {Target}.", + logTarget); + + return ShellOpenResult.Failure( + ShellOpenFailureKind.LaunchFailed, + target, + exception.Message); + } + } + + /// + /// Gets a sanitized label for URI logging. + /// + /// The URI being opened. + /// The URI host when available; otherwise, its scheme. + private static string GetUriLogTarget(Uri uri) + { + return string.IsNullOrWhiteSpace(uri.Host) + ? uri.Scheme + : uri.Host; + } + + /// + /// Opens a target through the Windows shell. + /// + /// The URL or local path to open. + [ExcludeFromCodeCoverage(Justification = "Calls the host shell; shell-open behavior is covered through the injected adapter.")] + private static void OpenShellTarget(string target) + { + Process.Start(new ProcessStartInfo(target) { UseShellExecute = true }); + } +} diff --git a/GenLauncherGO.Infrastructure/Startup/FileSystemLauncherPathResolver.cs b/GenLauncherGO.Infrastructure/Startup/FileSystemLauncherPathResolver.cs new file mode 100644 index 00000000..173d6d53 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Startup/FileSystemLauncherPathResolver.cs @@ -0,0 +1,232 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Startup; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Startup; + +/// +/// Resolves launcher paths by inspecting the local file system. +/// +public sealed class FileSystemLauncherPathResolver : ILauncherPathResolver +{ + /// + /// The launcher-owned directory name. + /// + private const string LauncherFolderName = "GenLauncherGO"; + + /// + /// The launcher runtime directory name. + /// + private const string RuntimeFolderName = "Runtime"; + + /// + /// The launcher cache directory name. + /// + private const string CacheFolderName = "Cache"; + + /// + /// The launcher image cache directory name. + /// + private const string ImagesFolderName = "Images"; + + /// + /// The launcher mods directory name. + /// + private const string ModsFolderName = "Mods"; + + /// + /// The launcher logs directory name. + /// + private const string LogsFolderName = "Logs"; + + /// + /// The launcher temporary directory name. + /// + private const string TempFolderName = "Temp"; + + /// + /// The launcher deployment state directory name. + /// + private const string DeploymentFolderName = "Deployment"; + + /// + /// The original game DLL required by supported game installs. + /// + private const string BinkLibraryFileName = "BINKW32.DLL"; + + /// + /// The Zero Hour data archive required by supported Zero Hour installs. + /// + private const string ZeroHourWindowArchiveFileName = "WindowZH.big"; + + /// + /// The Generals data archive required by supported Generals installs. + /// + private const string GeneralsWindowArchiveFileName = "Window.big"; + + /// + /// The GenLauncher replacement suffix used while game files are renamed. + /// + private const string ReplacementSuffix = ".GLR"; + + /// + /// The supported SuperHackers Zero Hour executable name. + /// + private const string SuperHackersZeroHourExecutableFileName = "generalszh.exe"; + + /// + /// The supported SuperHackers Generals executable name. + /// + private const string SuperHackersGeneralsExecutableFileName = "generalsv.exe"; + + /// + /// The supported GeneralsOnline executable name. + /// + private const string GeneralsOnlineExecutableFileName = "generalsonlinezh.exe"; + + /// + /// The logger used for file-system diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public FileSystemLauncherPathResolver() + : this(NullLogger.Instance) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The logger used for file-system diagnostics. + public FileSystemLauncherPathResolver(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public LauncherPaths? Resolve(string executableDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executableDirectory); + + string executableRoot = Path.GetFullPath(executableDirectory); + if (IsSupportedGameDirectory(executableRoot)) + { + return CreatePaths(executableRoot, Path.Combine(executableRoot, LauncherFolderName)); + } + + DirectoryInfo? parent = Directory.GetParent(executableRoot); + if (parent is not null && IsSupportedGameDirectory(parent.FullName)) + { + return CreatePaths(parent.FullName, executableRoot); + } + + _logger.LogWarning("No supported game directory was found for launcher executable directory {DirectoryName}.", + Path.GetFileName(executableRoot)); + return null; + } + + /// + public void PrepareLauncherDirectories(LauncherPaths paths, bool cleanTemporaryDirectory) + { + ArgumentNullException.ThrowIfNull(paths); + + Directory.CreateDirectory(paths.LauncherDirectory); + Directory.CreateDirectory(paths.RuntimeDirectory); + Directory.CreateDirectory(paths.CacheDirectory); + Directory.CreateDirectory(paths.ImagesDirectory); + Directory.CreateDirectory(paths.ModsDirectory); + Directory.CreateDirectory(paths.LogsDirectory); + Directory.CreateDirectory(paths.TempDirectory); + Directory.CreateDirectory(paths.DeploymentDirectory); + Directory.CreateDirectory(paths.StateDirectory); + + if (cleanTemporaryDirectory) + { + ClearDirectory(paths.TempDirectory); + } + + _logger.LogInformation("Prepared launcher directories under {LauncherDirectoryName}.", + Path.GetFileName(paths.LauncherDirectory)); + } + + /// + /// Creates a path model from resolved game and launcher roots. + /// + /// The supported game directory. + /// The launcher-owned directory. + /// The resolved launcher paths. + private static LauncherPaths CreatePaths(string gameDirectory, string launcherDirectory) + { + string launcherRoot = Path.GetFullPath(launcherDirectory); + string runtimeRoot = Path.Combine(launcherRoot, RuntimeFolderName); + string cacheRoot = Path.Combine(runtimeRoot, CacheFolderName); + return new LauncherPaths( + Path.GetFullPath(gameDirectory), + launcherRoot, + runtimeRoot, + cacheRoot, + Path.Combine(cacheRoot, ImagesFolderName), + Path.Combine(launcherRoot, ModsFolderName), + Path.Combine(launcherRoot, LogsFolderName), + Path.Combine(runtimeRoot, TempFolderName), + Path.Combine(runtimeRoot, DeploymentFolderName)); + } + + /// + /// Returns whether a directory contains a supported game installation. + /// + /// The directory to inspect. + /// when the directory contains supported game files. + private static bool IsSupportedGameDirectory(string directory) + { + if (!Directory.Exists(directory) || !File.Exists(Path.Combine(directory, BinkLibraryFileName))) + { + return false; + } + + bool hasZeroHourData = HasGameFile(directory, ZeroHourWindowArchiveFileName); + bool hasGeneralsData = HasGameFile(directory, GeneralsWindowArchiveFileName); + bool hasSupportedZeroHourExecutable = + File.Exists(Path.Combine(directory, SuperHackersZeroHourExecutableFileName)) || + File.Exists(Path.Combine(directory, GeneralsOnlineExecutableFileName)); + bool hasSupportedGeneralsExecutable = + File.Exists(Path.Combine(directory, SuperHackersGeneralsExecutableFileName)); + + return (hasZeroHourData && hasSupportedZeroHourExecutable) || + (hasGeneralsData && hasSupportedGeneralsExecutable); + } + + /// + /// Returns whether a game data file exists in its normal or renamed form. + /// + /// The directory to inspect. + /// The expected file name. + /// when the file exists. + private static bool HasGameFile(string directory, string fileName) + { + return File.Exists(Path.Combine(directory, fileName)) || + File.Exists(Path.Combine(directory, fileName + ReplacementSuffix)); + } + + /// + /// Deletes all child entries from a directory while keeping the directory itself. + /// + /// The directory to clear. + private static void ClearDirectory(string directory) + { + foreach (string filePath in Directory.EnumerateFiles(directory)) + { + File.Delete(filePath); + } + + foreach (string directoryPath in Directory.EnumerateDirectories(directory)) + { + Directory.Delete(directoryPath, recursive: true); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Startup/FileSystemLauncherStartupEnvironmentService.cs b/GenLauncherGO.Infrastructure/Startup/FileSystemLauncherStartupEnvironmentService.cs new file mode 100644 index 00000000..e1a3de4e --- /dev/null +++ b/GenLauncherGO.Infrastructure/Startup/FileSystemLauncherStartupEnvironmentService.cs @@ -0,0 +1,186 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Startup.Contracts; +using GenLauncherGO.Core.Startup.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using YamlDotNet.Core; +using YamlDotNet.Serialization; + +namespace GenLauncherGO.Infrastructure.Startup; + +/// +/// Reads launcher startup state from the local file system and current process. +/// +public sealed class FileSystemLauncherStartupEnvironmentService : ILauncherStartupEnvironmentService +{ + /// + /// The legacy custom color configuration file name. + /// + private const string ColorsFileName = "Colors.yaml"; + + /// + /// The legacy custom background image file name. + /// + private const string CustomBackgroundFileName = "GlBg.png"; + + /// + /// The Zero Hour game data archive file name. + /// + private const string ZeroHourWindowArchiveFileName = "WindowZH.big"; + + /// + /// The Generals game data archive file name. + /// + private const string GeneralsWindowArchiveFileName = "Window.big"; + + /// + /// The logger used for file-system diagnostics. + /// + private readonly ILogger _logger; + + /// + /// The YAML deserializer used for local color overrides. + /// + private readonly IDeserializer _deserializer; + + /// + /// Initializes a new instance of the class. + /// + public FileSystemLauncherStartupEnvironmentService() + : this(NullLogger.Instance) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The logger used for file-system diagnostics. + public FileSystemLauncherStartupEnvironmentService( + ILogger logger) + : this( + logger, + new DeserializerBuilder() + .IgnoreUnmatchedProperties() + .Build()) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The logger used for file-system diagnostics. + /// The YAML deserializer used for local color overrides. + internal FileSystemLauncherStartupEnvironmentService( + ILogger logger, + IDeserializer deserializer) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _deserializer = deserializer ?? throw new ArgumentNullException(nameof(deserializer)); + } + + /// + public Task ReadAsync( + LauncherPaths paths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(paths); + cancellationToken.ThrowIfCancellationRequested(); + + string gameDirectory = Path.GetFullPath(paths.GameDirectory); + SupportedGame managedGame = DetectManagedGame(gameDirectory); + ColorsInfoString? customColors = ReadCustomColors(gameDirectory); + string? customBackgroundImagePath = ResolveCustomBackgroundImagePath(gameDirectory); + + return Task.FromResult(new LauncherStartupEnvironment( + managedGame, + customColors, + customBackgroundImagePath)); + } + + /// + /// Detects the managed game variant from known game archive files. + /// + /// The game directory to inspect. + /// The detected game variant. + private static SupportedGame DetectManagedGame(string gameDirectory) + { + if (File.Exists(Path.Combine(gameDirectory, ZeroHourWindowArchiveFileName))) + { + return SupportedGame.ZeroHour; + } + + if (File.Exists(Path.Combine(gameDirectory, GeneralsWindowArchiveFileName))) + { + return SupportedGame.Generals; + } + + return SupportedGame.Unknown; + } + + /// + /// Resolves the custom background image path when the legacy image file exists. + /// + /// The game directory to inspect. + /// The custom background image path, or when none exists. + private static string? ResolveCustomBackgroundImagePath(string gameDirectory) + { + string imagePath = Path.Combine(gameDirectory, CustomBackgroundFileName); + return File.Exists(imagePath) + ? Path.GetFullPath(imagePath) + : null; + } + + /// + /// Reads local launcher color overrides from the legacy YAML file. + /// + /// The game directory to inspect. + /// The color override, or when no valid override exists. + private ColorsInfoString? ReadCustomColors(string gameDirectory) + { + string colorsFilePath = Path.Combine(gameDirectory, ColorsFileName); + if (!File.Exists(colorsFilePath)) + { + return null; + } + + try + { + using StreamReader reader = File.OpenText(colorsFilePath); + return _deserializer.Deserialize(reader); + } + catch (YamlException exception) + { + _logger.LogWarning( + exception, + "Could not parse launcher color override file {FileName}.", + ColorsFileName); + return null; + } + catch (Exception exception) when (IsRecoverableFileSystemException(exception)) + { + _logger.LogWarning( + exception, + "Could not read launcher color override file {FileName}.", + ColorsFileName); + return null; + } + } + + /// + /// Determines whether an exception represents a recoverable file-system problem. + /// + /// The exception to inspect. + /// when startup can continue without the optional file. + private static bool IsRecoverableFileSystemException(Exception exception) + { + return exception is IOException + or UnauthorizedAccessException + or NotSupportedException + or System.Security.SecurityException; + } +} diff --git a/GenLauncherGO.Infrastructure/Startup/WindowsLauncherHostEnvironmentService.cs b/GenLauncherGO.Infrastructure/Startup/WindowsLauncherHostEnvironmentService.cs new file mode 100644 index 00000000..97ea9c8d --- /dev/null +++ b/GenLauncherGO.Infrastructure/Startup/WindowsLauncherHostEnvironmentService.cs @@ -0,0 +1,198 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using System.Security.Principal; +using System.Threading; +using GenLauncherGO.Core.Startup.Contracts; +using GenLauncherGO.Infrastructure.Common; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Startup; + +/// +/// Provides Windows process, current-directory, single-instance, and foreground-window startup operations. +/// +public sealed class WindowsLauncherHostEnvironmentService : ILauncherHostEnvironmentService +{ + /// + /// The ShowWindow command that restores a minimized window. + /// + private const int SwRestore = 9; + + /// + /// The logger used for host-environment diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + public WindowsLauncherHostEnvironmentService() + : this(NullLogger.Instance) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The logger used for host-environment diagnostics. + public WindowsLauncherHostEnvironmentService(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + public void ActivateCurrentProcessWindow() + { + using var currentProcess = Process.GetCurrentProcess(); + Process? process = Process.GetProcessesByName(currentProcess.ProcessName) + .FirstOrDefault(candidate => candidate.Id != currentProcess.Id); + IntPtr windowHandle = process?.MainWindowHandle ?? IntPtr.Zero; + + if (windowHandle == IntPtr.Zero) + { + _logger.LogDebug("No existing launcher window was available to activate."); + return; + } + + ShowWindowAsync(new HandleRef(null, windowHandle), SwRestore); + SetForegroundWindow(windowHandle); + } + + /// + public string GetExecutableDirectory() + { + string? executablePath = Environment.ProcessPath ?? Process.GetCurrentProcess().MainModule?.FileName; + + if (String.IsNullOrWhiteSpace(executablePath)) + { + return AppContext.BaseDirectory; + } + + return Path.GetDirectoryName(executablePath) ?? AppContext.BaseDirectory; + } + + /// + public bool IsCurrentProcessElevated() + { + using var identity = WindowsIdentity.GetCurrent(); + WindowsPrincipal principal = new(identity); + return principal.IsInRole(WindowsBuiltInRole.Administrator); + } + + /// + public bool IsProtectedProgramFilesDirectory(string directory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + + string programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles); + string programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86); + + return IsPathInDirectoryWhenKnown(directory, programFiles) || + IsPathInDirectoryWhenKnown(directory, programFilesX86); + } + + /// + public void SetCurrentDirectory(string directory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + + Directory.SetCurrentDirectory(directory); + } + + /// + public ILauncherSingleInstanceGuard TryAcquireSingleInstance(string instanceName, TimeSpan retryDelay) + { + ArgumentException.ThrowIfNullOrWhiteSpace(instanceName); + ArgumentOutOfRangeException.ThrowIfLessThan(retryDelay, TimeSpan.Zero); + + Mutex mutex = new(initiallyOwned: true, instanceName, out bool createdNew); + if (createdNew) + { + return new MutexSingleInstanceGuard(mutex, isAcquired: true); + } + + mutex.Dispose(); + if (retryDelay > TimeSpan.Zero) + { + Thread.Sleep(retryDelay); + } + + mutex = new Mutex(initiallyOwned: true, instanceName, out createdNew); + if (createdNew) + { + return new MutexSingleInstanceGuard(mutex, isAcquired: true); + } + + mutex.Dispose(); + return MutexSingleInstanceGuard.NotAcquired; + } + + /// + /// Returns whether a path is equal to or below a known directory. + /// + /// The path to inspect. + /// The potential parent directory. + /// when the directory is known and contains the path. + private static bool IsPathInDirectoryWhenKnown(string path, string directory) + { + return !String.IsNullOrWhiteSpace(directory) && + FileSystemPathSafety.IsPathInDirectory(path, directory); + } + + /// + /// Sets a foreground window. + /// + /// The window handle. + /// when the native call succeeds. + [DllImport("user32.dll")] + private static extern bool SetForegroundWindow(IntPtr hWnd); + + /// + /// Shows a window asynchronously. + /// + /// The window handle reference. + /// The show command. + /// when the native call succeeds. + [DllImport("user32.dll")] + private static extern bool ShowWindowAsync(HandleRef hWnd, int nCmdShow); + + /// + /// Owns a Windows mutex used as the single-instance guard. + /// + private sealed class MutexSingleInstanceGuard : ILauncherSingleInstanceGuard + { + /// + /// A non-acquired guard. + /// + public static readonly MutexSingleInstanceGuard NotAcquired = new(null, isAcquired: false); + + /// + /// The owned mutex when acquired. + /// + private readonly Mutex? _mutex; + + /// + /// Initializes a new instance of the class. + /// + /// The owned mutex. + /// A value indicating whether the mutex was acquired. + public MutexSingleInstanceGuard(Mutex? mutex, bool isAcquired) + { + _mutex = mutex; + IsAcquired = isAcquired; + } + + /// + public bool IsAcquired { get; } + + /// + public void Dispose() + { + _mutex?.Dispose(); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Clients/HttpDownloadFileMetadataReader.cs b/GenLauncherGO.Infrastructure/Updating/Clients/HttpDownloadFileMetadataReader.cs new file mode 100644 index 00000000..9c80181f --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Clients/HttpDownloadFileMetadataReader.cs @@ -0,0 +1,135 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Remote; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Updating.Clients; + +/// +/// Reads downloadable file metadata over HTTP. +/// +public sealed class HttpDownloadFileMetadataReader : IDownloadFileMetadataReader +{ + private static readonly HttpClient _sharedHttpClient = + SharedHttpClientFactory.Create(TimeSpan.FromSeconds(60)); + + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP client used for metadata requests. + /// The logger used for metadata diagnostics. + public HttpDownloadFileMetadataReader( + HttpClient? httpClient = null, + ILogger? logger = null) + { + _httpClient = httpClient ?? _sharedHttpClient; + _logger = logger ?? NullLogger.Instance; + } + + /// + public async Task ReadMetadataAsync( + Uri downloadUri, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(downloadUri); + + try + { + DownloadFileMetadata? metadata = await TryReadMetadataAsync( + downloadUri, + HttpMethod.Head, + cancellationToken).ConfigureAwait(false); + if (metadata is not null) + { + return metadata; + } + + metadata = await TryReadMetadataAsync( + downloadUri, + HttpMethod.Get, + cancellationToken).ConfigureAwait(false); + if (metadata is not null) + { + return metadata; + } + + _logger.LogWarning( + "Remote download metadata did not include a file name for {Scheme}://{Host}.", + downloadUri.Scheme, + downloadUri.Host); + throw new InvalidOperationException( + "Download link is incorrect, please contact modification creator and try again later."); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to read remote download metadata from {Scheme}://{Host}.", + downloadUri.Scheme, + downloadUri.Host); + throw; + } + } + + private async Task TryReadMetadataAsync( + Uri downloadUri, + HttpMethod httpMethod, + CancellationToken cancellationToken) + { + using HttpRequestMessage request = new(httpMethod, downloadUri); + using HttpResponseMessage response = await _httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + + if (httpMethod == HttpMethod.Head && + (response.StatusCode == HttpStatusCode.MethodNotAllowed || + response.StatusCode == HttpStatusCode.NotImplemented)) + { + return null; + } + + response.EnsureSuccessStatusCode(); + + string? fileName = response.Content.Headers.ContentDisposition?.FileNameStar; + if (string.IsNullOrWhiteSpace(fileName)) + { + fileName = response.Content.Headers.ContentDisposition?.FileName; + } + + if (string.IsNullOrWhiteSpace(fileName)) + { + return null; + } + + return new DownloadFileMetadata( + downloadUri, + SanitizeFileName(fileName), + response.Content.Headers.ContentLength); + } + + private static string SanitizeFileName(string fileName) + { + string sanitizedFileName = fileName.Trim('"').Replace("\\", string.Empty).Replace("/", string.Empty); + if (string.IsNullOrWhiteSpace(sanitizedFileName)) + { + throw new InvalidOperationException( + "Download link is incorrect, please contact modification creator and try again later."); + } + + return sanitizedFileName; + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Clients/MinioClientFactory.cs b/GenLauncherGO.Infrastructure/Updating/Clients/MinioClientFactory.cs new file mode 100644 index 00000000..f6df99a4 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Clients/MinioClientFactory.cs @@ -0,0 +1,56 @@ +using System; +using Minio; + +namespace GenLauncherGO.Infrastructure.Updating.Clients; + +/// +/// Creates MinIO clients from S3-compatible endpoint settings. +/// +internal static class MinioClientFactory +{ + /// + /// Creates an authenticated MinIO client for the supplied endpoint and credentials. + /// + /// The S3-compatible endpoint host or URI. + /// The access key used for S3 authentication. + /// The secret key used for S3 authentication. + /// + /// The SSL preference for host-only endpoints. Explicit endpoint URI schemes override this value. + /// + /// An authenticated MinIO client. + public static IMinioClient Create( + string endpoint, + string accessKey, + string secretKey, + bool useSsl = true) + { + ArgumentException.ThrowIfNullOrWhiteSpace(endpoint); + ArgumentException.ThrowIfNullOrWhiteSpace(accessKey); + ArgumentException.ThrowIfNullOrWhiteSpace(secretKey); + + string normalizedEndpoint = endpoint.Trim(); + bool resolvedUseSsl = useSsl; + + if (normalizedEndpoint.Contains("://", StringComparison.OrdinalIgnoreCase) && + Uri.TryCreate(normalizedEndpoint, UriKind.Absolute, out Uri? endpointUri)) + { + normalizedEndpoint = endpointUri.Authority; + resolvedUseSsl = string.Equals(endpointUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase); + } + else if (normalizedEndpoint.EndsWith(":443", StringComparison.OrdinalIgnoreCase)) + { + resolvedUseSsl = true; + } + + IMinioClient client = new MinioClient() + .WithEndpoint(normalizedEndpoint) + .WithCredentials(accessKey, secretKey); + + if (resolvedUseSsl) + { + return client.WithSSL().Build(); + } + + return client.Build(); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Clients/MinioS3ObjectManifestReader.cs b/GenLauncherGO.Infrastructure/Updating/Clients/MinioS3ObjectManifestReader.cs new file mode 100644 index 00000000..32ffece6 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Clients/MinioS3ObjectManifestReader.cs @@ -0,0 +1,182 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using Minio; +using Minio.DataModel; +using Minio.DataModel.Args; +using Microsoft.Extensions.Logging; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Clients; + +/// +/// Reads S3-compatible object listings with MinIO. +/// +public sealed class MinioS3ObjectManifestReader : IS3ObjectManifestReader +{ + /// + /// Lists object storage entries for a request. + /// + private readonly Func> _listObjects; + + /// + /// The logger used for S3 manifest diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger used for S3 manifest diagnostics. + public MinioS3ObjectManifestReader(ILogger logger) + : this(logger, ListObjectsAsync) + { + } + + /// + /// Initializes a new instance of the class with a listing adapter. + /// + /// The logger used for S3 manifest diagnostics. + /// Lists object storage entries for a request. + internal MinioS3ObjectManifestReader( + ILogger logger, + Func> listObjects) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _listObjects = listObjects ?? throw new ArgumentNullException(nameof(listObjects)); + } + + /// + /// Reads an authenticated S3-compatible object listing, returning manifest entries with prefix-relative names. + /// + /// The S3 object manifest request. + /// A token that cancels listing work. + /// The listed remote file manifest entries. + public async Task> ReadManifestAsync( + S3ObjectManifestRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Endpoint); + ArgumentException.ThrowIfNullOrWhiteSpace(request.BucketName); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Prefix); + ArgumentException.ThrowIfNullOrWhiteSpace(request.AccessKey); + ArgumentException.ThrowIfNullOrWhiteSpace(request.SecretKey); + + CultureInfo previousCulture = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = CreateInvariantS3Culture(); + + try + { + List files = new(); + await foreach (S3ObjectManifestItem item in _listObjects(request, cancellationToken) + .ConfigureAwait(false)) + { + files.Add(new RemoteFileManifestEntry( + StripPrefix(item.Key, request.Prefix), + NormalizeETag(item.ETag), + item.Size)); + } + + _logger.LogInformation( + "Read {FileCount} S3 manifest entries from bucket {BucketName}, prefix {Prefix}.", + files.Count, + request.BucketName, + request.Prefix); + return files; + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + } + } + + /// + /// Lists object storage entries using the MinIO SDK. + /// + /// The S3 object manifest request. + /// A token that cancels listing work. + /// The listed object storage entries. + [ExcludeFromCodeCoverage(Justification = "Wraps MinIO SDK network enumeration; behavior is covered through the injected listing adapter.")] + private static async IAsyncEnumerable ListObjectsAsync( + S3ObjectManifestRequest request, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + IMinioClient client = MinioClientFactory.Create( + request.Endpoint, + request.AccessKey, + request.SecretKey, + request.UseSsl); + + ListObjectsArgs args = new ListObjectsArgs() + .WithBucket(request.BucketName) + .WithPrefix(request.Prefix) + .WithRecursive(true); + + await foreach (Item item in client.ListObjectsEnumAsync(args, cancellationToken) + .ConfigureAwait(false)) + { + yield return new S3ObjectManifestItem( + item.Key, + item.ETag, + item.Size); + } + } + + private static CultureInfo CreateInvariantS3Culture() + { + CultureInfo culture = new("en-US") + { + DateTimeFormat = new DateTimeFormatInfo + { + Calendar = new GregorianCalendar(), + }, + }; + + return culture; + } + + /// + /// Removes the listed S3 prefix from an object key when the key is under that prefix. + /// + /// The full object key. + /// The prefix supplied to the listing request. + /// The prefix-relative key when possible; otherwise, the original key. + private static string StripPrefix(string key, string prefix) + { + string normalizedPrefix = prefix.TrimEnd('/') + "/"; + if (key.StartsWith(normalizedPrefix, StringComparison.Ordinal)) + { + return key[normalizedPrefix.Length..]; + } + + return key; + } + + /// + /// Removes surrounding whitespace and quotes from an S3 ETag value. + /// + /// The ETag returned by object storage. + /// The normalized ETag value. + private static string NormalizeETag(string eTag) + { + return eTag.Trim().Trim('"'); + } + + /// + /// Describes one object storage entry read from the S3-compatible manifest prefix. + /// + /// The object key. + /// The object ETag. + /// The object size. + internal sealed record S3ObjectManifestItem( + string Key, + string ETag, + ulong Size); +} diff --git a/GenLauncherGO.Infrastructure/Updating/Clients/ResumableHttpFileDownloader.cs b/GenLauncherGO.Infrastructure/Updating/Clients/ResumableHttpFileDownloader.cs new file mode 100644 index 00000000..d932210d --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Clients/ResumableHttpFileDownloader.cs @@ -0,0 +1,438 @@ +using System; +using System.Buffers; +using System.Diagnostics; +using System.Globalization; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Options; + +namespace GenLauncherGO.Infrastructure.Updating.Clients; + +/// +/// Downloads files over HTTP using range requests, pooled buffers, retry backoff, and idle-transfer detection. +/// +public sealed class ResumableHttpFileDownloader : IResumableFileDownloader +{ + private static readonly HttpClient _sharedHttpClient = CreateSharedHttpClient(); + + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + private readonly ResumableHttpDownloadOptions _options; + + /// + /// Initializes a new instance of the class. + /// + /// The HTTP client used for transfers. A shared client is used when omitted. + /// The logger used for download diagnostics. + /// The transfer options. + public ResumableHttpFileDownloader( + HttpClient? httpClient = null, + ILogger? logger = null, + ResumableHttpDownloadOptions? options = null) + { + _httpClient = httpClient ?? _sharedHttpClient; + _logger = logger ?? NullLogger.Instance; + _options = options ?? new ResumableHttpDownloadOptions(); + + if (_options.BufferSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(options), "Download buffer size must be greater than zero."); + } + + if (_options.MaxAttempts <= 0) + { + throw new ArgumentOutOfRangeException(nameof(options), "Maximum attempts must be greater than zero."); + } + + if (_options.IdleTimeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(options), "Idle timeout must be greater than zero."); + } + + if (_options.ProgressReportInterval <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(options), + "Progress report interval must be greater than zero."); + } + } + + /// + public async Task DownloadFileAsync( + DownloadFileRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + if (!request.SourceUri.IsAbsoluteUri) + { + throw new ArgumentException("Download source URI must be absolute.", nameof(request)); + } + + if (!string.Equals(request.SourceUri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + !string.Equals(request.SourceUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Download source URI must use HTTP or HTTPS.", nameof(request)); + } + + ArgumentException.ThrowIfNullOrWhiteSpace(request.DestinationFilePath); + + string destinationFilePath = Path.GetFullPath(request.DestinationFilePath); + Directory.CreateDirectory(Path.GetDirectoryName(destinationFilePath) ?? "."); + + Exception? lastException = null; + for (int attempt = 1; attempt <= _options.MaxAttempts; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + return await DownloadAttemptAsync( + request with { DestinationFilePath = destinationFilePath }, + progress, + cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) when (IsRetriable(ex) && attempt < _options.MaxAttempts) + { + lastException = ex; + _logger.LogWarning( + ex, + "Download attempt {Attempt} failed for {FileName}; retrying.", + attempt, + Path.GetFileName(destinationFilePath)); + + await Task.Delay(GetRetryDelay(attempt), cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) when (IsRetriable(ex)) + { + lastException = ex; + break; + } + } + + throw new IOException( + string.Format( + CultureInfo.InvariantCulture, + "Download failed after {0} attempts.", + _options.MaxAttempts), + lastException); + } + + /// + /// Creates the shared HTTP client used when no caller-provided client is supplied. + /// + /// A configured HTTP client for long-running downloads. + private static HttpClient CreateSharedHttpClient() + { + SocketsHttpHandler handler = new() + { + AutomaticDecompression = DecompressionMethods.None, + ConnectTimeout = TimeSpan.FromSeconds(30), + MaxConnectionsPerServer = 16, + PooledConnectionIdleTimeout = TimeSpan.FromMinutes(2), + PooledConnectionLifetime = TimeSpan.FromMinutes(15), + }; + + return new HttpClient(handler) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + } + + /// + /// Performs one transfer attempt, resuming from local content when the server accepts the requested range. + /// + /// The normalized download request. + /// Optional progress reporter for the transfer. + /// A token that cancels the transfer. + /// The completed transfer result. + private async Task DownloadAttemptAsync( + DownloadFileRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + long existingBytes = GetExistingBytes(request); + if (request.ExpectedBytes.HasValue && existingBytes == request.ExpectedBytes.Value) + { + ReportProgress(progress, request.ExpectedBytes, existingBytes); + return new DownloadFileResult(request.DestinationFilePath, existingBytes, true); + } + + using HttpRequestMessage message = new(HttpMethod.Get, request.SourceUri); + if (existingBytes > 0) + { + message.Headers.Range = new RangeHeaderValue(existingBytes, null); + } + + using HttpResponseMessage response = await _httpClient.SendAsync( + message, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + + if (response.StatusCode == HttpStatusCode.RequestedRangeNotSatisfiable && + request.ExpectedBytes.HasValue && + existingBytes == request.ExpectedBytes.Value) + { + ReportProgress(progress, request.ExpectedBytes, existingBytes); + return new DownloadFileResult(request.DestinationFilePath, existingBytes, true); + } + + response.EnsureSuccessStatusCode(); + + bool partialContentResponse = response.StatusCode == HttpStatusCode.PartialContent; + bool serverAcceptedResume = existingBytes > 0 && + partialContentResponse && + ResponseStartsAtExpectedByte(response, existingBytes); + if (existingBytes > 0 && partialContentResponse && !serverAcceptedResume) + { + _logger.LogWarning( + "Server returned an unexpected byte range for {FileName}; restarting download from byte zero.", + Path.GetFileName(request.DestinationFilePath)); + + File.Delete(request.DestinationFilePath); + throw new IOException("Server returned an unexpected byte range for a resumed download."); + } + + if (existingBytes > 0 && !serverAcceptedResume) + { + _logger.LogInformation( + "Server did not honor range request for {FileName}; restarting from byte zero.", + Path.GetFileName(request.DestinationFilePath)); + + existingBytes = 0; + } + + long? totalBytes = ResolveTotalBytes(request, response, existingBytes, serverAcceptedResume); + FileMode fileMode = existingBytes > 0 && serverAcceptedResume ? FileMode.Append : FileMode.Create; + long bytesDownloaded = existingBytes; + bool resumed = existingBytes > 0 && serverAcceptedResume; + + ReportProgress(progress, totalBytes, bytesDownloaded); + + await using FileStream destinationStream = new( + request.DestinationFilePath, + fileMode, + FileAccess.Write, + FileShare.Read, + _options.BufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + + await using Stream responseStream = await response.Content.ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + + bytesDownloaded = await CopyToFileAsync( + responseStream, + destinationStream, + totalBytes, + bytesDownloaded, + progress, + cancellationToken).ConfigureAwait(false); + + if (totalBytes.HasValue && bytesDownloaded != totalBytes.Value) + { + throw new IOException( + string.Format( + CultureInfo.InvariantCulture, + "Downloaded {0} bytes, but expected {1} bytes.", + bytesDownloaded, + totalBytes.Value)); + } + + return new DownloadFileResult(request.DestinationFilePath, bytesDownloaded, resumed); + } + + /// + /// Gets the number of local bytes that can be used for a resume attempt. + /// + /// The download request. + /// The existing byte count, or zero when a fresh download is required. + private long GetExistingBytes(DownloadFileRequest request) + { + if (!request.Resume || !File.Exists(request.DestinationFilePath)) + { + return 0; + } + + long existingBytes = new FileInfo(request.DestinationFilePath).Length; + if (request.ExpectedBytes.HasValue && existingBytes > request.ExpectedBytes.Value) + { + _logger.LogInformation( + "Existing partial file {FileName} is larger than expected; restarting download.", + Path.GetFileName(request.DestinationFilePath)); + + return 0; + } + + return existingBytes; + } + + /// + /// Resolves the expected final byte count from the request and response headers. + /// + /// The download request. + /// The HTTP response. + /// The existing local byte count. + /// Whether the server accepted the requested byte range. + /// The expected final byte count when it can be determined. + private static long? ResolveTotalBytes( + DownloadFileRequest request, + HttpResponseMessage response, + long existingBytes, + bool serverAcceptedResume) + { + if (serverAcceptedResume && response.Content.Headers.ContentRange?.Length is long contentRangeLength) + { + return contentRangeLength; + } + + if (request.ExpectedBytes.HasValue) + { + return request.ExpectedBytes.Value; + } + + if (response.Content.Headers.ContentLength is long contentLength) + { + return serverAcceptedResume ? existingBytes + contentLength : contentLength; + } + + return null; + } + + /// + /// Returns whether a partial-content response starts at the byte requested by the resume attempt. + /// + /// The HTTP response returned by the server. + /// The local file length used as the requested range start. + /// when the server returned the expected content range. + private static bool ResponseStartsAtExpectedByte( + HttpResponseMessage response, + long expectedStartByte) + { + return response.Content.Headers.ContentRange?.From == expectedStartByte; + } + + /// + /// Copies response content to disk while reporting throttled progress and detecting idle transfers. + /// + /// The HTTP response stream. + /// The destination file stream. + /// The expected final byte count when known. + /// The number of bytes already present locally. + /// Optional progress reporter for the transfer. + /// A token that cancels the transfer. + /// The final downloaded byte count. + private async Task CopyToFileAsync( + Stream responseStream, + FileStream destinationStream, + long? totalBytes, + long bytesDownloaded, + IProgress? progress, + CancellationToken cancellationToken) + { + byte[] buffer = ArrayPool.Shared.Rent(_options.BufferSize); + var progressStopwatch = Stopwatch.StartNew(); + TimeSpan lastReportElapsed = TimeSpan.Zero; + + try + { + using var idleCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken); + + while (true) + { + idleCancellation.CancelAfter(_options.IdleTimeout); + + int bytesRead; + try + { + bytesRead = await responseStream + .ReadAsync(buffer.AsMemory(0, _options.BufferSize), idleCancellation.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + throw new TimeoutException("Download stalled while waiting for response data."); + } + + if (bytesRead == 0) + { + break; + } + + idleCancellation.CancelAfter(Timeout.InfiniteTimeSpan); + + await destinationStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken) + .ConfigureAwait(false); + + bytesDownloaded += bytesRead; + if (progressStopwatch.Elapsed - lastReportElapsed >= _options.ProgressReportInterval) + { + ReportProgress(progress, totalBytes, bytesDownloaded); + lastReportElapsed = progressStopwatch.Elapsed; + } + } + + ReportProgress(progress, totalBytes, bytesDownloaded); + return bytesDownloaded; + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + /// + /// Returns whether an exception represents a transient transfer failure. + /// + /// The exception to inspect. + /// when retrying the transfer may succeed. + private static bool IsRetriable(Exception ex) + { + return ex is HttpRequestException or IOException or TimeoutException || + ex is TaskCanceledException; + } + + /// + /// Calculates the bounded exponential backoff delay for a retry attempt. + /// + /// The one-based attempt number that just failed. + /// The delay before the next attempt. + private TimeSpan GetRetryDelay(int attempt) + { + double multiplier = Math.Pow(2, Math.Max(0, attempt - 1)); + double delayMilliseconds = _options.InitialRetryDelay.TotalMilliseconds * multiplier; + return TimeSpan.FromMilliseconds(Math.Min(delayMilliseconds, TimeSpan.FromSeconds(30).TotalMilliseconds)); + } + + /// + /// Reports transfer progress to the optional progress sink. + /// + /// The progress sink. + /// The expected final byte count when known. + /// The local byte count completed so far. + private static void ReportProgress( + IProgress? progress, + long? totalBytes, + long bytesDownloaded) + { + double? percentage = null; + if (totalBytes is > 0) + { + percentage = Math.Round((double)bytesDownloaded / totalBytes.Value * 100, 2); + } + + progress?.Report(new DownloadProgress(totalBytes, bytesDownloaded, percentage)); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Composition/UpdatingServiceCollectionExtensions.cs b/GenLauncherGO.Infrastructure/Updating/Composition/UpdatingServiceCollectionExtensions.cs new file mode 100644 index 00000000..8938b610 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Composition/UpdatingServiceCollectionExtensions.cs @@ -0,0 +1,47 @@ +using System; +using GenLauncherGO.Core.Remote; +using GenLauncherGO.Infrastructure.Archives; +using GenLauncherGO.Infrastructure.Remote; +using Microsoft.Extensions.DependencyInjection; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Clients; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Options; +using GenLauncherGO.Infrastructure.Updating.Services; + +namespace GenLauncherGO.Infrastructure.Updating.Composition; + +/// +/// Provides dependency-injection registration helpers for update infrastructure services. +/// +public static class UpdatingServiceCollectionExtensions +{ + /// + /// Registers update and download infrastructure services used by GenLauncherGO workflows. + /// + /// The service collection used by the application composition root. + /// The same service collection so additional registrations can be chained. + /// + /// Thrown when is . + /// + public static IServiceCollection AddGenLauncherGoUpdating(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddLogging(); + services.AddGenLauncherGoArchives(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Contracts/IS3ObjectManifestReader.cs b/GenLauncherGO.Infrastructure/Updating/Contracts/IS3ObjectManifestReader.cs new file mode 100644 index 00000000..bbfd0643 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Contracts/IS3ObjectManifestReader.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Contracts; + +/// +/// Reads file manifests from S3-compatible object storage. +/// +public interface IS3ObjectManifestReader +{ + /// + /// Lists the files under a remote S3 prefix. + /// + /// The S3 listing request. + /// The token used to cancel the request. + /// The remote file manifest entries. + Task> ReadManifestAsync( + S3ObjectManifestRequest request, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Updating/Contracts/IS3PackageUpdater.cs b/GenLauncherGO.Infrastructure/Updating/Contracts/IS3PackageUpdater.cs new file mode 100644 index 00000000..c1ee540f --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Contracts/IS3PackageUpdater.cs @@ -0,0 +1,35 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Contracts; + +/// +/// Updates a package from S3-compatible object storage. +/// +public interface IS3PackageUpdater +{ + /// + /// Downloads and installs the requested package. + /// + /// The update request. + /// Optional progress reporter. + /// The token used to cancel the update. + Task UpdateAsync( + S3PackageUpdateRequest request, + IProgress? progress, + CancellationToken cancellationToken); + + /// + /// Downloads and repairs selected package files directly inside an installed S3-backed package. + /// + /// The file repair request. + /// Optional progress reporter. + /// The token used to cancel the repair. + Task RepairFilesAsync( + S3PackageFileRepairRequest request, + IProgress? progress, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Updating/Models/S3ObjectManifestRequest.cs b/GenLauncherGO.Infrastructure/Updating/Models/S3ObjectManifestRequest.cs new file mode 100644 index 00000000..0cb77bfb --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Models/S3ObjectManifestRequest.cs @@ -0,0 +1,21 @@ +namespace GenLauncherGO.Infrastructure.Updating.Models; + +/// +/// Describes an S3-compatible object listing request for a modification version. +/// +/// The S3 endpoint host or URI. +/// The bucket name. +/// The object prefix to list. +/// The access key. +/// The secret key. +/// +/// The SSL preference for host-only endpoints. Explicit URI schemes override this value. Defaults to +/// to preserve compatibility with catalog endpoints that use plain MinIO ports. +/// +public sealed record S3ObjectManifestRequest( + string Endpoint, + string BucketName, + string Prefix, + string AccessKey, + string SecretKey, + bool UseSsl = false); diff --git a/GenLauncherGO.Infrastructure/Updating/Models/S3PackageFileRepairRequest.cs b/GenLauncherGO.Infrastructure/Updating/Models/S3PackageFileRepairRequest.cs new file mode 100644 index 00000000..d7e64ec4 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Models/S3PackageFileRepairRequest.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Models; + +/// +/// Describes selected S3-backed package files that should be repaired in place. +/// +/// The remote manifest entries to repair. +/// The S3 endpoint host or URI. +/// The S3 bucket name. +/// The S3 folder/prefix containing the package files. +/// The access key used to authorize object downloads. +/// The secret key used to authorize object downloads. +/// The installed folder path to repair in place. +/// Extensions that require hash validation. +/// +/// The SSL preference for host-only endpoints. Explicit URI schemes override this value. Defaults to +/// to preserve compatibility with catalog endpoints that use plain MinIO ports. +/// +public sealed record S3PackageFileRepairRequest( + IReadOnlyList Files, + string Endpoint, + string BucketName, + string FolderName, + string AccessKey, + string SecretKey, + string InstalledFolderPath, + IReadOnlySet HashCheckedExtensions, + bool UseSsl = false); diff --git a/GenLauncherGO.Infrastructure/Updating/Models/S3PackageUpdateRequest.cs b/GenLauncherGO.Infrastructure/Updating/Models/S3PackageUpdateRequest.cs new file mode 100644 index 00000000..05fbee04 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Models/S3PackageUpdateRequest.cs @@ -0,0 +1,34 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Models; + +/// +/// Describes an S3-backed package update. +/// +/// The remote file manifest. +/// The S3 endpoint host or URI. +/// The S3 bucket name. +/// The S3 folder/prefix containing the package files. +/// The access key used to authorize object downloads. +/// The secret key used to authorize object downloads. +/// The temporary folder used during update. +/// The final installed folder path. +/// The previous installed folder path used for unchanged-file reuse. +/// Extensions that require hash validation. +/// +/// The SSL preference for host-only endpoints. Explicit URI schemes override this value. Defaults to +/// to preserve compatibility with catalog endpoints that use plain MinIO ports. +/// +public sealed record S3PackageUpdateRequest( + IReadOnlyList Files, + string Endpoint, + string BucketName, + string FolderName, + string AccessKey, + string SecretKey, + string TemporaryFolderPath, + string InstalledFolderPath, + string? LatestInstalledFolderPath, + IReadOnlySet HashCheckedExtensions, + bool UseSsl = false); diff --git a/GenLauncherGO.Infrastructure/Updating/Options/ResumableHttpDownloadOptions.cs b/GenLauncherGO.Infrastructure/Updating/Options/ResumableHttpDownloadOptions.cs new file mode 100644 index 00000000..92018afa --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Options/ResumableHttpDownloadOptions.cs @@ -0,0 +1,39 @@ +using System; + +namespace GenLauncherGO.Infrastructure.Updating.Options; + +/// +/// Defines transfer behavior for resumable HTTP downloads. +/// +public sealed class ResumableHttpDownloadOptions +{ + /// + /// Gets the default transfer buffer size. + /// + public const int DefaultBufferSize = 1024 * 1024; + + /// + /// Gets or initializes the buffer size used while copying response content to disk. + /// + public int BufferSize { get; init; } = DefaultBufferSize; + + /// + /// Gets or initializes the maximum number of attempts for transient download failures. + /// + public int MaxAttempts { get; init; } = 5; + + /// + /// Gets or initializes the maximum allowed idle time between successful reads. + /// + public TimeSpan IdleTimeout { get; init; } = TimeSpan.FromSeconds(30); + + /// + /// Gets or initializes the minimum elapsed time between progress reports while bytes are actively downloading. + /// + public TimeSpan ProgressReportInterval { get; init; } = TimeSpan.FromMilliseconds(100); + + /// + /// Gets or initializes the initial retry delay before exponential backoff is applied. + /// + public TimeSpan InitialRetryDelay { get; init; } = TimeSpan.FromSeconds(1); +} diff --git a/GenLauncherGO.Infrastructure/Updating/Options/S3PackageUpdateOptions.cs b/GenLauncherGO.Infrastructure/Updating/Options/S3PackageUpdateOptions.cs new file mode 100644 index 00000000..24da4f13 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Options/S3PackageUpdateOptions.cs @@ -0,0 +1,19 @@ +using System; + +namespace GenLauncherGO.Infrastructure.Updating.Options; + +/// +/// Defines package update behavior for S3-backed modifications. +/// +public sealed class S3PackageUpdateOptions +{ + /// + /// Gets or initializes the maximum number of files downloaded concurrently for one package. + /// + public int MaxConcurrentFileDownloads { get; init; } = 6; + + /// + /// Gets or initializes the lifetime of generated presigned S3 download URLs. + /// + public TimeSpan PresignedUrlLifetime { get; init; } = TimeSpan.FromHours(12); +} diff --git a/GenLauncherGO.Infrastructure/Updating/Services/HttpSingleFilePackageDownloadOperation.cs b/GenLauncherGO.Infrastructure/Updating/Services/HttpSingleFilePackageDownloadOperation.cs new file mode 100644 index 00000000..eacba5da --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/HttpSingleFilePackageDownloadOperation.cs @@ -0,0 +1,228 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Updating.Services; + +/// +/// Runs a launcher package download from a single remote file. +/// +internal sealed class HttpSingleFilePackageDownloadOperation : IPackageDownloadOperation +{ + /// + /// The updater used to download and install the package. + /// + private readonly ISingleFilePackageUpdater _packageUpdater; + + /// + /// The resolver used to compute installed package paths. + /// + private readonly ILauncherContentPathResolver _contentPathResolver; + + /// + /// The launcher runtime paths. + /// + private readonly LauncherPaths _launcherPaths; + + /// + /// The launcher content folder layout. + /// + private readonly LauncherContentLayout _contentLayout; + + /// + /// The package download request. + /// + private readonly ModificationPackageDownloadRequest _request; + + /// + /// The logger used for package download operation diagnostics. + /// + private readonly ILogger _logger; + + /// + /// The cancellation source for the active download. + /// + private CancellationTokenSource _downloadCancellation = new CancellationTokenSource(); + + /// + /// The current download result. + /// + private PackageDownloadResult _downloadResult = new PackageDownloadResult(); + + /// + /// Initializes a new instance of the class. + /// + /// The updater used to download and install the package. + /// The resolver used to compute installed package paths. + /// The launcher runtime paths. + /// The launcher content folder layout. + /// The package download request. + /// The logger used for package download operation diagnostics. + public HttpSingleFilePackageDownloadOperation( + ISingleFilePackageUpdater packageUpdater, + ILauncherContentPathResolver contentPathResolver, + LauncherPaths launcherPaths, + LauncherContentLayout contentLayout, + ModificationPackageDownloadRequest request, + ILogger? logger = null) + { + _packageUpdater = packageUpdater ?? throw new ArgumentNullException(nameof(packageUpdater)); + _contentPathResolver = contentPathResolver ?? throw new ArgumentNullException(nameof(contentPathResolver)); + _launcherPaths = launcherPaths ?? throw new ArgumentNullException(nameof(launcherPaths)); + _contentLayout = contentLayout ?? throw new ArgumentNullException(nameof(contentLayout)); + _request = request ?? throw new ArgumentNullException(nameof(request)); + _logger = logger ?? NullLogger.Instance; + } + + /// + public event Action? ProgressChanged; + + /// + public event Action? Done; + + /// + public PackageDownloadReadiness GetPackageDownloadReadiness() + { + return new PackageDownloadReadiness { ReadyToDownload = true }; + } + + /// + public async Task StartDownloadModificationAsync() + { + _downloadResult = new PackageDownloadResult(); + _downloadCancellation.Dispose(); + _downloadCancellation = new CancellationTokenSource(); + + try + { + string downloadUrl = + DownloadLinkResolver.ResolveDirectDownloadLink(_request.Modification.SimpleDownloadLink); + string temporaryFolderPath = GetTempCopyOfFolder(); + string installedFolderPath = GetInstalledFolderPath(_request.LatestVersion); + + _logger.LogInformation( + "Starting single-file package download for {ContentName} {ContentVersion}.", + _request.LatestVersion.Name, + _request.LatestVersion.Version); + + IProgress progress = new Progress(TriggerProgressChanged); + + await _packageUpdater.UpdateAsync( + new SingleFilePackageUpdateRequest( + new Uri(downloadUrl, UriKind.Absolute), + temporaryFolderPath, + installedFolderPath), + progress, + _downloadCancellation.Token); + + _logger.LogInformation( + "Completed single-file package download for {ContentName} {ContentVersion}.", + _request.LatestVersion.Name, + _request.LatestVersion.Version); + Complete(); + } + catch (OperationCanceledException) + { + _logger.LogInformation( + "Canceled single-file package download for {ContentName} {ContentVersion}.", + _request.LatestVersion.Name, + _request.LatestVersion.Version); + _downloadResult = new PackageDownloadResult + { + Canceled = true, + Message = "Download Canceled", + }; + Complete(); + } + catch (Exception exception) + { + _logger.LogError( + exception, + "Single-file package download failed for {ContentName} {ContentVersion}.", + _request.LatestVersion.Name, + _request.LatestVersion.Version); + _downloadResult = new PackageDownloadResult + { + Crashed = true, + Message = exception.InnerException?.Message ?? exception.Message, + }; + Complete(); + } + } + + /// + public void CancelDownload() + { + _logger.LogInformation( + "Cancellation requested for single-file package download {ContentName} {ContentVersion}.", + _request.LatestVersion.Name, + _request.LatestVersion.Version); + _downloadResult = _downloadResult with { Canceled = true }; + _downloadCancellation.Cancel(); + } + + /// + public PackageDownloadResult GetResult() + { + return _downloadResult; + } + + /// + public void Dispose() + { + _downloadCancellation.Cancel(); + _downloadCancellation.Dispose(); + } + + /// + /// Creates and returns the temporary package folder. + /// + /// The temporary package folder path. + private string GetTempCopyOfFolder() + { + string tempFolderName = _launcherPaths.GetPackageTemporaryFolderPath( + GetInstalledFolderPath(_request.LatestVersion)); + + Directory.CreateDirectory(tempFolderName); + return tempFolderName; + } + + /// + /// Gets the installed folder path for a modification version. + /// + /// The modification version. + /// The installed folder path. + private string GetInstalledFolderPath(ModificationVersion version) + { + return _contentPathResolver.GetVersionDirectoryPath( + _launcherPaths, + _contentLayout, + version); + } + + /// + /// Raises the progress changed event. + /// + /// The package update progress. + private void TriggerProgressChanged(PackageUpdateProgress progress) + { + ProgressChanged?.Invoke(progress); + } + + /// + /// Raises the completion event. + /// + private void Complete() + { + Done?.Invoke(_downloadResult); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Services/Md5FileHashService.cs b/GenLauncherGO.Infrastructure/Updating/Services/Md5FileHashService.cs new file mode 100644 index 00000000..4383fba5 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/Md5FileHashService.cs @@ -0,0 +1,59 @@ +using System; +using System.Globalization; +using System.IO; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Contracts; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Updating.Services; + +/// +/// Computes MD5 hashes for local files. +/// +public sealed class Md5FileHashService : IFileHashService +{ + /// + /// The logger used for hash computation diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger used for hash computation diagnostics. + public Md5FileHashService(ILogger? logger = null) + { + _logger = logger ?? NullLogger.Instance; + } + + /// + public async Task ComputeMd5HashAsync(string filePath, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + + try + { + await using FileStream fileStream = new( + filePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 1024 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + + byte[] hash = await MD5.HashDataAsync(fileStream, cancellationToken).ConfigureAwait(false); + return Convert.ToHexString(hash).ToUpper(CultureInfo.InvariantCulture); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or CryptographicException) + { + _logger.LogWarning( + exception, + "Failed to compute MD5 hash for {FileName}.", + Path.GetFileName(filePath)); + throw; + } + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Services/PackageDownloadOperationFactory.cs b/GenLauncherGO.Infrastructure/Updating/Services/PackageDownloadOperationFactory.cs new file mode 100644 index 00000000..8d285718 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/PackageDownloadOperationFactory.cs @@ -0,0 +1,135 @@ +using System; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Updating.Services; + +/// +/// Creates package download operations backed by HTTP or S3-compatible storage. +/// +public sealed class PackageDownloadOperationFactory : IPackageDownloadOperationFactory +{ + /// + /// The updater used for single-file package downloads. + /// + private readonly ISingleFilePackageUpdater _singleFilePackageUpdater; + + /// + /// The updater used for S3-compatible package downloads. + /// + private readonly IS3PackageUpdater _s3PackageUpdater; + + /// + /// The reader used to enumerate S3 package manifests. + /// + private readonly IS3ObjectManifestReader _s3ObjectManifestReader; + + /// + /// The clock service used to validate S3 request readiness. + /// + private readonly ISystemClockService _systemClockService; + + /// + /// The resolver used to compute launcher content paths. + /// + private readonly ILauncherContentPathResolver _contentPathResolver; + + /// + /// The launcher runtime paths. + /// + private readonly LauncherPaths _launcherPaths; + + /// + /// The launcher content folder layout. + /// + private readonly LauncherContentLayout _contentLayout; + + /// + /// Creates operation-specific loggers. + /// + private readonly ILoggerFactory _loggerFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The updater used for single-file package downloads. + /// The updater used for S3-compatible package downloads. + /// The reader used to enumerate S3 package manifests. + /// The clock service used to validate S3 request readiness. + /// The resolver used to compute launcher content paths. + /// The launcher runtime paths. + /// The launcher content folder layout. + /// The factory used to create operation-specific loggers. + public PackageDownloadOperationFactory( + ISingleFilePackageUpdater singleFilePackageUpdater, + IS3PackageUpdater s3PackageUpdater, + IS3ObjectManifestReader s3ObjectManifestReader, + ISystemClockService systemClockService, + ILauncherContentPathResolver contentPathResolver, + LauncherPaths launcherPaths, + LauncherContentLayout contentLayout, + ILoggerFactory? loggerFactory = null) + { + _singleFilePackageUpdater = singleFilePackageUpdater ?? + throw new ArgumentNullException(nameof(singleFilePackageUpdater)); + _s3PackageUpdater = s3PackageUpdater ?? throw new ArgumentNullException(nameof(s3PackageUpdater)); + _s3ObjectManifestReader = s3ObjectManifestReader ?? + throw new ArgumentNullException(nameof(s3ObjectManifestReader)); + _systemClockService = systemClockService ?? throw new ArgumentNullException(nameof(systemClockService)); + _contentPathResolver = contentPathResolver ?? throw new ArgumentNullException(nameof(contentPathResolver)); + _launcherPaths = launcherPaths ?? throw new ArgumentNullException(nameof(launcherPaths)); + _contentLayout = contentLayout ?? throw new ArgumentNullException(nameof(contentLayout)); + _loggerFactory = loggerFactory ?? NullLoggerFactory.Instance; + } + + /// + public IPackageDownloadOperation Create( + ModificationPackageDownloadRequest request, + bool forceSingleFileDownload = false) + { + ArgumentNullException.ThrowIfNull(request); + + if (ShouldUseSingleFileDownload(request.LatestVersion, forceSingleFileDownload)) + { + return new HttpSingleFilePackageDownloadOperation( + _singleFilePackageUpdater, + _contentPathResolver, + _launcherPaths, + _contentLayout, + request, + _loggerFactory.CreateLogger()); + } + + return new S3PackageDownloadOperation( + _s3PackageUpdater, + _s3ObjectManifestReader, + _systemClockService, + _contentPathResolver, + _launcherPaths, + _contentLayout, + request, + _loggerFactory.CreateLogger()); + } + + /// + /// Determines whether a request should use the single-file download flow. + /// + /// The latest package version. + /// Whether single-file download was explicitly requested. + /// when the single-file download flow should be used. + private static bool ShouldUseSingleFileDownload( + ModificationVersion latestVersion, + bool forceSingleFileDownload) + { + return string.IsNullOrWhiteSpace(latestVersion.S3HostLink) || + string.IsNullOrWhiteSpace(latestVersion.S3BucketName) || + string.IsNullOrWhiteSpace(latestVersion.S3FolderName) || + forceSingleFileDownload; + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Services/S3PackageDownloadOperation.cs b/GenLauncherGO.Infrastructure/Updating/Services/S3PackageDownloadOperation.cs new file mode 100644 index 00000000..5fbb5968 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/S3PackageDownloadOperation.cs @@ -0,0 +1,334 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Minio.Exceptions; + +namespace GenLauncherGO.Infrastructure.Updating.Services; + +/// +/// Runs a launcher package download from S3-compatible object storage. +/// +internal sealed class S3PackageDownloadOperation : IPackageDownloadOperation +{ + /// + /// Extensions that require hash validation during S3 package installation. + /// + private static readonly HashSet _extensionsToCheckHash = + new HashSet(StringComparer.OrdinalIgnoreCase) + { + ".w3d", + ".big", + ".bik", + ".gib", + ".dds", + ".tga", + ".ini", + ".scb", + ".wnd", + ".csf", + ".str", + }; + + /// + /// The updater used to download and install the package. + /// + private readonly IS3PackageUpdater _packageUpdater; + + /// + /// The reader used to enumerate remote S3 package files. + /// + private readonly IS3ObjectManifestReader _manifestReader; + + /// + /// The clock service used to detect request-signature time problems. + /// + private readonly ISystemClockService _systemClockService; + + /// + /// The resolver used to compute installed package paths. + /// + private readonly ILauncherContentPathResolver _contentPathResolver; + + /// + /// The launcher runtime paths. + /// + private readonly LauncherPaths _launcherPaths; + + /// + /// The launcher content folder layout. + /// + private readonly LauncherContentLayout _contentLayout; + + /// + /// The package download request. + /// + private readonly ModificationPackageDownloadRequest _request; + + /// + /// The logger used for package download operation diagnostics. + /// + private readonly ILogger _logger; + + /// + /// The cancellation source for the active download. + /// + private CancellationTokenSource _downloadCancellation = new CancellationTokenSource(); + + /// + /// The current download result. + /// + private PackageDownloadResult _downloadResult = new PackageDownloadResult(); + + /// + /// Initializes a new instance of the class. + /// + /// The updater used to download and install the package. + /// The reader used to enumerate remote S3 package files. + /// The clock service used to detect request-signature time problems. + /// The resolver used to compute installed package paths. + /// The launcher runtime paths. + /// The launcher content folder layout. + /// The package download request. + /// The logger used for package download operation diagnostics. + public S3PackageDownloadOperation( + IS3PackageUpdater packageUpdater, + IS3ObjectManifestReader manifestReader, + ISystemClockService systemClockService, + ILauncherContentPathResolver contentPathResolver, + LauncherPaths launcherPaths, + LauncherContentLayout contentLayout, + ModificationPackageDownloadRequest request, + ILogger? logger = null) + { + _packageUpdater = packageUpdater ?? throw new ArgumentNullException(nameof(packageUpdater)); + _manifestReader = manifestReader ?? throw new ArgumentNullException(nameof(manifestReader)); + _systemClockService = systemClockService ?? throw new ArgumentNullException(nameof(systemClockService)); + _contentPathResolver = contentPathResolver ?? throw new ArgumentNullException(nameof(contentPathResolver)); + _launcherPaths = launcherPaths ?? throw new ArgumentNullException(nameof(launcherPaths)); + _contentLayout = contentLayout ?? throw new ArgumentNullException(nameof(contentLayout)); + _request = request ?? throw new ArgumentNullException(nameof(request)); + _logger = logger ?? NullLogger.Instance; + } + + /// + public event Action? ProgressChanged; + + /// + public event Action? Done; + + /// + public PackageDownloadReadiness GetPackageDownloadReadiness() + { + if (_systemClockService.IsSystemTimeOutOfSync()) + { + _logger.LogWarning( + "S3 package download readiness failed because system time appears out of sync for {ContentName} {ContentVersion}.", + _request.LatestVersion.Name, + _request.LatestVersion.Version); + return new PackageDownloadReadiness + { + ReadyToDownload = false, + Error = PackageDownloadReadinessError.TimeOutOfSync, + }; + } + + return new PackageDownloadReadiness { ReadyToDownload = true }; + } + + /// + public async Task StartDownloadModificationAsync() + { + _downloadResult = new PackageDownloadResult(); + _downloadCancellation.Dispose(); + _downloadCancellation = new CancellationTokenSource(); + + try + { + _logger.LogInformation( + "Starting S3 package download for {ContentName} {ContentVersion}.", + _request.LatestVersion.Name, + _request.LatestVersion.Version); + + IReadOnlyList repositoryFilesInfo = await GetFilesInfoFromS3StorageAsync( + _manifestReader, + _request.LatestVersion, + _downloadCancellation.Token); + ModificationVersion? latestInstalledVersion = _request.Modification.ModificationVersions + .OrderBy(version => version) + .Where(version => version.Installed) + .LastOrDefault(); + + IProgress progress = new Progress( + TriggerProgressChanged); + + await _packageUpdater.UpdateAsync( + CreateUpdateRequest(repositoryFilesInfo, latestInstalledVersion), + progress, + _downloadCancellation.Token); + + _logger.LogInformation( + "Completed S3 package download for {ContentName} {ContentVersion}.", + _request.LatestVersion.Name, + _request.LatestVersion.Version); + Complete(); + } + catch (OperationCanceledException) + { + _logger.LogInformation( + "Canceled S3 package download for {ContentName} {ContentVersion}.", + _request.LatestVersion.Name, + _request.LatestVersion.Version); + _downloadResult = new PackageDownloadResult + { + Canceled = true, + Message = "Download Canceled", + }; + Complete(); + } + catch (UnexpectedMinioException exception) + { + _logger.LogError( + exception, + "S3 package download failed with an unexpected MinIO error for {ContentName} {ContentVersion}.", + _request.LatestVersion.Name, + _request.LatestVersion.Version); + _downloadResult = new PackageDownloadResult + { + Crashed = true, + Message = "Unexpected Minio API Exception. Try to sync your system time", + }; + Complete(); + } + catch (Exception exception) + { + _logger.LogError( + exception, + "S3 package download failed for {ContentName} {ContentVersion}.", + _request.LatestVersion.Name, + _request.LatestVersion.Version); + _downloadResult = new PackageDownloadResult + { + Crashed = true, + Message = exception.InnerException?.Message ?? exception.Message, + }; + Complete(); + } + } + + /// + public void CancelDownload() + { + _logger.LogInformation( + "Cancellation requested for S3 package download {ContentName} {ContentVersion}.", + _request.LatestVersion.Name, + _request.LatestVersion.Version); + _downloadResult = _downloadResult with { Canceled = true }; + _downloadCancellation.Cancel(); + } + + /// + public PackageDownloadResult GetResult() + { + return _downloadResult; + } + + /// + public void Dispose() + { + _downloadCancellation.Cancel(); + _downloadCancellation.Dispose(); + } + + /// + /// Creates the S3 package update request. + /// + /// The remote repository file manifest. + /// The latest installed version when available. + /// The S3 package update request. + private S3PackageUpdateRequest CreateUpdateRequest( + IReadOnlyList repositoryFilesInfo, + ModificationVersion? latestInstalledVersion) + { + return new S3PackageUpdateRequest( + repositoryFilesInfo, + _request.LatestVersion.S3HostLink, + _request.LatestVersion.S3BucketName, + _request.LatestVersion.S3FolderName, + S3CatalogDefaults.ResolveAccessKey(_request.LatestVersion), + S3CatalogDefaults.ResolveSecretKey(_request.LatestVersion), + GetTempCopyOfFolder(), + GetInstalledFolderPath(_request.LatestVersion), + latestInstalledVersion != null ? GetInstalledFolderPath(latestInstalledVersion) : null, + _extensionsToCheckHash); + } + + /// + /// Gets the temporary package folder path. + /// + /// The temporary package folder path. + private string GetTempCopyOfFolder() + { + return _launcherPaths.GetPackageTemporaryFolderPath( + GetInstalledFolderPath(_request.LatestVersion)); + } + + /// + /// Gets the installed folder path for a modification version. + /// + /// The modification version. + /// The installed folder path. + private string GetInstalledFolderPath(ModificationVersion version) + { + return _contentPathResolver.GetVersionDirectoryPath( + _launcherPaths, + _contentLayout, + version); + } + + /// + /// Raises the progress changed event. + /// + /// The package update progress. + private void TriggerProgressChanged(PackageUpdateProgress progress) + { + ProgressChanged?.Invoke(progress); + } + + /// + /// Reads remote S3 file metadata for a package version. + /// + /// The manifest reader. + /// The package version. + /// The token used to cancel the read. + /// The remote file manifest. + private static async Task> GetFilesInfoFromS3StorageAsync( + IS3ObjectManifestReader manifestReader, + ModificationVersion latestVersion, + CancellationToken cancellationToken) + { + S3ObjectManifestRequest request = S3CatalogDefaults.CreateManifestRequest(latestVersion); + return await manifestReader.ReadManifestAsync( + request, + cancellationToken); + } + + /// + /// Raises the completion event. + /// + private void Complete() + { + Done?.Invoke(_downloadResult); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Services/S3PackageUpdater.cs b/GenLauncherGO.Infrastructure/Updating/Services/S3PackageUpdater.cs new file mode 100644 index 00000000..e34dc23b --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/S3PackageUpdater.cs @@ -0,0 +1,619 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Minio; +using Minio.DataModel.Args; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Options; +using GenLauncherGO.Infrastructure.Updating.Support; + +namespace GenLauncherGO.Infrastructure.Updating.Services; + +/// +/// Updates a package from S3-compatible object storage. +/// +public sealed class S3PackageUpdater : IS3PackageUpdater +{ + private const int MaxHashAttempts = 3; + + private readonly IResumableFileDownloader _fileDownloader; + private readonly IFileHashService _fileHashService; + private readonly ILogger _logger; + private readonly S3PackageUpdateOptions _options; + private readonly S3ReusablePackageFileCopier _reusableFileCopier; + + /// + /// Initializes a new instance of the class. + /// + /// The downloader used for presigned object URLs. + /// The hash service used to validate files when reliable hashes are available. + /// The logger used for package update diagnostics. + /// The S3 package update options. + public S3PackageUpdater( + IResumableFileDownloader fileDownloader, + IFileHashService fileHashService, + ILogger logger, + S3PackageUpdateOptions options) + { + _fileDownloader = fileDownloader ?? throw new ArgumentNullException(nameof(fileDownloader)); + _fileHashService = fileHashService ?? throw new ArgumentNullException(nameof(fileHashService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _options = options ?? throw new ArgumentNullException(nameof(options)); + _reusableFileCopier = new S3ReusablePackageFileCopier(_fileHashService, _logger); + + if (_options.MaxConcurrentFileDownloads <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(options), + "Maximum concurrent S3 file downloads must be greater than zero."); + } + + if (_options.PresignedUrlLifetime <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException( + nameof(options), + "Presigned S3 URL lifetime must be greater than zero."); + } + } + + /// + /// Downloads missing package files to the temporary folder, reuses unchanged files from the latest installed + /// version, validates reliable hashes, converts downloaded .big files to .gib, and stages the + /// temporary folder into the installed package location. + /// + /// The S3 package update request. + /// Optional progress reporter for package download status. + /// A token that cancels reuse, download, validation, and replacement work. + /// A task that completes after the package folder has been replaced. + public async Task UpdateAsync( + S3PackageUpdateRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentException.ThrowIfNullOrWhiteSpace(request.AccessKey); + ArgumentException.ThrowIfNullOrWhiteSpace(request.SecretKey); + ArgumentException.ThrowIfNullOrWhiteSpace(request.TemporaryFolderPath); + ArgumentException.ThrowIfNullOrWhiteSpace(request.InstalledFolderPath); + + Directory.CreateDirectory(request.TemporaryFolderPath); + PackageStagingFolderCleaner.RemoveUnsafeLinks( + request.TemporaryFolderPath, + _logger, + cancellationToken); + _logger.LogInformation( + "Starting S3 package update for bucket {BucketName}, folder {FolderName}; files: {FileCount}.", + request.BucketName, + request.FolderName, + request.Files.Count); + + if (!string.IsNullOrWhiteSpace(request.LatestInstalledFolderPath) && + Directory.Exists(request.LatestInstalledFolderPath)) + { + await _reusableFileCopier.CopyUnchangedFilesAsync( + request.LatestInstalledFolderPath, + request.TemporaryFolderPath, + request.Files, + cancellationToken).ConfigureAwait(false); + } + + var downloadContext = S3PackageDownloadContext.FromUpdateRequest(request); + List downloadWorkItems = await CreateDownloadWorkItemsAsync( + downloadContext, + cancellationToken).ConfigureAwait(false); + long totalDownloadSize = downloadWorkItems.Sum(download => download.BytesToDownload); + var progressState = new PackageProgressTracker(totalDownloadSize); + if (downloadWorkItems.Count == 0) + { + progress?.Report(new PackageUpdateProgress(0, 0, 100, null)); + } + + using SemaphoreSlim downloadSlots = new(_options.MaxConcurrentFileDownloads); + IMinioClient client = Clients.MinioClientFactory.Create( + downloadContext.Endpoint, + downloadContext.AccessKey, + downloadContext.SecretKey, + downloadContext.UseSsl); + + var downloadTasks = downloadWorkItems + .Select(download => DownloadFileWithSlotAsync( + downloadContext, + client, + download, + progressState, + progress, + downloadSlots, + cancellationToken)) + .ToList(); + + await Task.WhenAll(downloadTasks).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + PackageStagingFolderCleaner.PruneToManifest( + request.TemporaryFolderPath, + request.Files, + _logger, + cancellationToken); + + cancellationToken.ThrowIfCancellationRequested(); + PackageInstallFolderReplacer.Replace(request.TemporaryFolderPath, request.InstalledFolderPath, _logger); + PackageStagingFolderCleaner.DeleteEmptyPackageParents(request.TemporaryFolderPath, _logger); + _logger.LogInformation( + "Completed S3 package update for bucket {BucketName}, folder {FolderName}.", + request.BucketName, + request.FolderName); + } + + /// + /// Downloads selected S3 manifest files directly into an installed package folder, validating reliable hashes and + /// preserving unrelated installed files. + /// + /// The S3 file repair request. + /// Optional progress reporter for package repair status. + /// A token that cancels validation and download work. + /// A task that completes after all selected files are repaired. + public async Task RepairFilesAsync( + S3PackageFileRepairRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentException.ThrowIfNullOrWhiteSpace(request.AccessKey); + ArgumentException.ThrowIfNullOrWhiteSpace(request.SecretKey); + ArgumentException.ThrowIfNullOrWhiteSpace(request.InstalledFolderPath); + + Directory.CreateDirectory(request.InstalledFolderPath); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + request.InstalledFolderPath, + "Installed package folder contains a linked path and cannot be repaired safely."); + _logger.LogInformation( + "Starting S3 package file repair for bucket {BucketName}, folder {FolderName}; files: {FileCount}.", + request.BucketName, + request.FolderName, + request.Files.Count); + + var downloadContext = S3PackageDownloadContext.FromRepairRequest(request); + List downloadWorkItems = await CreateDownloadWorkItemsAsync( + downloadContext, + cancellationToken).ConfigureAwait(false); + long totalDownloadSize = downloadWorkItems.Sum(download => download.BytesToDownload); + var progressState = new PackageProgressTracker(totalDownloadSize); + if (downloadWorkItems.Count == 0) + { + progress?.Report(new PackageUpdateProgress(0, 0, 100, null)); + } + + using SemaphoreSlim downloadSlots = new(_options.MaxConcurrentFileDownloads); + IMinioClient client = Clients.MinioClientFactory.Create( + downloadContext.Endpoint, + downloadContext.AccessKey, + downloadContext.SecretKey, + downloadContext.UseSsl); + + var downloadTasks = downloadWorkItems + .Select(download => DownloadFileWithSlotAsync( + downloadContext, + client, + download, + progressState, + progress, + downloadSlots, + cancellationToken)) + .ToList(); + + await Task.WhenAll(downloadTasks).ConfigureAwait(false); + _logger.LogInformation( + "Completed S3 package file repair for bucket {BucketName}, folder {FolderName}.", + request.BucketName, + request.FolderName); + } + + /// + /// Creates the set of manifest files that still require remote transfer after reusable files are staged. + /// + /// The package download context. + /// A token that cancels validation work. + /// The files that need to be downloaded and the expected transfer bytes for each file. + private async Task> CreateDownloadWorkItemsAsync( + S3PackageDownloadContext context, + CancellationToken cancellationToken) + { + List downloads = new(); + foreach (RemoteFileManifestEntry file in context.Files) + { + string destinationFilePath = ManifestPathResolver.ResolvePath( + context.DestinationFolderPath, + file.FileName); + if (await CheckFileSuccessDownloadAsync( + file, + destinationFilePath, + context.HashCheckedExtensions, + cancellationToken).ConfigureAwait(false)) + { + continue; + } + + Directory.CreateDirectory(Path.GetDirectoryName(destinationFilePath) ?? context.DestinationFolderPath); + BigFileVariantPath.PrepareBigFileResumePath(destinationFilePath); + + long expectedBytes = (long)file.Size; + long existingBytes = GetExistingBytesForProgress(destinationFilePath); + if (existingBytes >= expectedBytes) + { + DeleteFailedFile(destinationFilePath); + existingBytes = 0; + } + + downloads.Add(new S3DownloadWorkItem( + file, + Math.Max(0, existingBytes), + Math.Max(0, expectedBytes - existingBytes))); + } + + return downloads; + } + + /// + /// Returns whether an existing downloaded file has the expected size. + /// + /// The manifest file entry. + /// The expected destination path. + /// when the local file exists and size matches. + public static bool ExistingDownloadedFileMatchesExpectedSize( + RemoteFileManifestEntry file, + string destinationFilePath) + { + string existingFilePath = BigFileVariantPath.GetExistingDownloadedPath(destinationFilePath); + return !string.IsNullOrWhiteSpace(existingFilePath) && + new FileInfo(existingFilePath).Length == (long)file.Size; + } + + /// + /// Waits for a download slot and downloads one verified manifest file. + /// + /// The package download context. + /// The MinIO client used to create presigned URLs. + /// The manifest file and expected transfer byte counts. + /// The aggregate progress tracker. + /// Optional package progress reporter. + /// The semaphore limiting concurrent downloads. + /// A token that cancels the download. + private async Task DownloadFileWithSlotAsync( + S3PackageDownloadContext context, + IMinioClient client, + S3DownloadWorkItem download, + PackageProgressTracker progressState, + IProgress? progress, + SemaphoreSlim downloadSlots, + CancellationToken cancellationToken) + { + await downloadSlots.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await DownloadVerifiedFileAsync( + context, + client, + download, + progressState, + progress, + cancellationToken).ConfigureAwait(false); + } + finally + { + downloadSlots.Release(); + } + } + + /// + /// Downloads a file, validates size and hash when available, and retries hash mismatches. + /// + /// The package download context. + /// The MinIO client used to create presigned URLs. + /// The manifest file and expected transfer byte counts. + /// The aggregate progress tracker. + /// Optional package progress reporter. + /// A token that cancels the download. + private async Task DownloadVerifiedFileAsync( + S3PackageDownloadContext context, + IMinioClient client, + S3DownloadWorkItem download, + PackageProgressTracker progressState, + IProgress? progress, + CancellationToken cancellationToken) + { + RemoteFileManifestEntry file = download.File; + string destinationFilePath = ManifestPathResolver.ResolvePath( + context.DestinationFolderPath, + file.FileName); + Directory.CreateDirectory(Path.GetDirectoryName(destinationFilePath) ?? context.DestinationFolderPath); + + for (int attempt = 1; attempt <= MaxHashAttempts; attempt++) + { + BigFileVariantPath.PrepareBigFileResumePath(destinationFilePath); + long resumeOffset = attempt == 1 ? download.ExistingBytes : 0; + long expectedTransferBytes = Math.Max(0, (long)file.Size - resumeOffset); + + Uri downloadUri = await BuildDownloadUriAsync(context, client, file.FileName).ConfigureAwait(false); + IProgress downloadProgress = new Progress(report => + { + if (resumeOffset > 0 && report.BytesDownloaded < resumeOffset) + { + progressState.AddExpectedBytes(resumeOffset); + expectedTransferBytes += resumeOffset; + resumeOffset = 0; + } + + long transferredBytes = Math.Min( + Math.Max(0, report.BytesDownloaded - resumeOffset), + expectedTransferBytes); + bool completed = report.TotalBytes.HasValue && report.BytesDownloaded >= report.TotalBytes.Value; + ReportFileProgress( + progressState, + progress, + file.FileName, + transferredBytes, + completed); + }); + + await _fileDownloader.DownloadFileAsync( + new DownloadFileRequest( + downloadUri, + destinationFilePath, + (long)file.Size, + Resume: true), + downloadProgress, + cancellationToken).ConfigureAwait(false); + + BigFileVariantPath.ConvertBigFileToGib(destinationFilePath); + + if (await CheckFileSuccessDownloadAsync( + file, + destinationFilePath, + context.HashCheckedExtensions, + cancellationToken).ConfigureAwait(false)) + { + ReportFileProgress( + progressState, + progress, + file.FileName, + expectedTransferBytes, + forceReport: true); + return; + } + + if (attempt == MaxHashAttempts) + { + throw new IOException("Hash sum mismatch detected after repeated download attempts."); + } + + _logger.LogWarning( + "Hash validation failed for {FileName}; retrying download attempt {NextAttempt}.", + file.FileName, + attempt + 1); + DeleteFailedFile(destinationFilePath); + ReportFileProgress(progressState, progress, file.FileName, 0, forceReport: true); + await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Returns whether the staged file exists, has the expected size, and passes required hash validation. + /// + /// The manifest file entry. + /// The requested staged file path. + /// Extensions that require hash validation. + /// A token that cancels hashing. + /// when the staged file satisfies the manifest entry. + private async Task CheckFileSuccessDownloadAsync( + RemoteFileManifestEntry file, + string destinationFilePath, + IReadOnlySet hashCheckedExtensions, + CancellationToken cancellationToken) + { + string existingFilePath = BigFileVariantPath.GetExistingDownloadedPath(destinationFilePath); + if (string.IsNullOrWhiteSpace(existingFilePath)) + { + return false; + } + + if (!ExistingDownloadedFileMatchesExpectedSize(file, destinationFilePath)) + { + return false; + } + + if (!S3HashValidationPolicy.ShouldCheckHash(file, hashCheckedExtensions)) + { + return true; + } + + string hashSum = await _fileHashService.ComputeMd5HashAsync(existingFilePath, cancellationToken) + .ConfigureAwait(false); + + return string.Equals(hashSum, file.Hash, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Creates a presigned S3 object download URI for a manifest file. + /// + /// The package download context. + /// The MinIO client used to create the URL. + /// The manifest-relative file name. + /// The presigned object download URI. + private async Task BuildDownloadUriAsync( + S3PackageDownloadContext context, + IMinioClient client, + string fileName) + { + int expirySeconds = (int)Math.Min( + _options.PresignedUrlLifetime.TotalSeconds, + int.MaxValue); + string objectName = BuildObjectName(context.FolderName, fileName); + PresignedGetObjectArgs args = new PresignedGetObjectArgs() + .WithBucket(context.BucketName) + .WithObject(objectName) + .WithExpiry(expirySeconds); + string presignedUrl = await client.PresignedGetObjectAsync(args).ConfigureAwait(false); + return new Uri(presignedUrl, UriKind.Absolute); + } + + /// + /// Gets the existing staged byte count used for progress reporting before a resume attempt. + /// + /// The requested staged file path. + /// The existing byte count, or zero when no staged variant exists. + private static long GetExistingBytesForProgress(string destinationFilePath) + { + string existingPath = BigFileVariantPath.GetExistingDownloadedPath(destinationFilePath); + if (string.IsNullOrWhiteSpace(existingPath)) + { + return 0; + } + + return new FileInfo(existingPath).Length; + } + + /// + /// Deletes failed .big and .gib staged variants before retrying a download. + /// + /// The requested staged file path. + private void DeleteFailedFile(string destinationFilePath) + { + if (File.Exists(destinationFilePath)) + { + File.Delete(destinationFilePath); + _logger.LogInformation( + "Deleted failed downloaded file {FileName}.", + Path.GetFileName(destinationFilePath)); + } + + string gibFilePath = Path.ChangeExtension(destinationFilePath, ".gib"); + if (File.Exists(gibFilePath)) + { + File.Delete(gibFilePath); + _logger.LogInformation( + "Deleted failed converted file {FileName}.", + Path.GetFileName(gibFilePath)); + } + } + + /// + /// Builds the S3 object name from a folder prefix and manifest-relative file name. + /// + /// The S3 folder or prefix. + /// The manifest-relative file name. + /// The slash-separated object name. + private static string BuildObjectName(string folderName, string fileName) + { + string normalizedFolderName = folderName.Replace('\\', '/').Trim('/'); + string normalizedFileName = ManifestPathResolver.NormalizeForManifestIndex(fileName); + if (string.IsNullOrWhiteSpace(normalizedFolderName)) + { + return normalizedFileName; + } + + return $"{normalizedFolderName}/{normalizedFileName}"; + } + + /// + /// Updates aggregate file progress and reports when a new package progress value is available. + /// + /// The aggregate progress tracker. + /// Optional package progress reporter. + /// The manifest-relative file name. + /// The completed byte count for the file. + /// A value indicating whether throttling should be bypassed. + private static void ReportFileProgress( + PackageProgressTracker progressState, + IProgress? progress, + string fileName, + long bytesRead, + bool forceReport = false) + { + PackageUpdateProgress? report = progressState.Update(fileName, bytesRead, forceReport); + if (report is not null) + { + progress?.Report(report); + } + } + + /// + /// Describes the shared download inputs for full S3 updates and selected file repairs. + /// + /// The remote manifest entries to download or validate. + /// The S3 endpoint host or URI. + /// The S3 bucket name. + /// The S3 folder/prefix containing the package files. + /// The access key used to authorize object downloads. + /// The secret key used to authorize object downloads. + /// The local root folder that receives the manifest files. + /// Extensions that require hash validation. + /// A value indicating whether host-only endpoints should use SSL. + private sealed record S3PackageDownloadContext( + IReadOnlyList Files, + string Endpoint, + string BucketName, + string FolderName, + string AccessKey, + string SecretKey, + string DestinationFolderPath, + IReadOnlySet HashCheckedExtensions, + bool UseSsl) + { + /// + /// Creates a download context for a full package update. + /// + /// The full package update request. + /// The download context. + public static S3PackageDownloadContext FromUpdateRequest(S3PackageUpdateRequest request) + { + return new S3PackageDownloadContext( + request.Files, + request.Endpoint, + request.BucketName, + request.FolderName, + request.AccessKey, + request.SecretKey, + request.TemporaryFolderPath, + request.HashCheckedExtensions, + request.UseSsl); + } + + /// + /// Creates a download context for an in-place file repair. + /// + /// The in-place file repair request. + /// The download context. + public static S3PackageDownloadContext FromRepairRequest(S3PackageFileRepairRequest request) + { + return new S3PackageDownloadContext( + request.Files, + request.Endpoint, + request.BucketName, + request.FolderName, + request.AccessKey, + request.SecretKey, + request.InstalledFolderPath, + request.HashCheckedExtensions, + request.UseSsl); + } + } + + /// + /// Describes one manifest file that still requires remote transfer after local reuse validation. + /// + /// The remote manifest entry. + /// The local resumable byte count that should not be counted as new transfer. + /// The expected byte count that still needs to be transferred. + private sealed record S3DownloadWorkItem( + RemoteFileManifestEntry File, + long ExistingBytes, + long BytesToDownload); +} diff --git a/GenLauncherGO.Infrastructure/Updating/Services/SingleFilePackageUpdater.cs b/GenLauncherGO.Infrastructure/Updating/Services/SingleFilePackageUpdater.cs new file mode 100644 index 00000000..799e97fa --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/SingleFilePackageUpdater.cs @@ -0,0 +1,126 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Archives; +using Microsoft.Extensions.Logging; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; + +namespace GenLauncherGO.Infrastructure.Updating.Services; + +/// +/// Updates a package from a single remote file. +/// +public sealed class SingleFilePackageUpdater : ISingleFilePackageUpdater +{ + private readonly IArchiveExtractor _archiveExtractor; + private readonly IResumableFileDownloader _fileDownloader; + private readonly ILogger _logger; + private readonly IDownloadFileMetadataReader _metadataReader; + + /// + /// Initializes a new instance of the class. + /// + /// The downloader used for the remote package file. + /// The metadata reader used to resolve the downloadable file name and size. + /// The archive extractor used when the remote package is an archive. + /// The logger used for package update diagnostics. + public SingleFilePackageUpdater( + IResumableFileDownloader fileDownloader, + IDownloadFileMetadataReader metadataReader, + IArchiveExtractor archiveExtractor, + ILogger logger) + { + _fileDownloader = fileDownloader ?? throw new ArgumentNullException(nameof(fileDownloader)); + _metadataReader = metadataReader ?? throw new ArgumentNullException(nameof(metadataReader)); + _archiveExtractor = archiveExtractor ?? throw new ArgumentNullException(nameof(archiveExtractor)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Downloads a single remote package file, optionally extracts it, removes the downloaded archive, and stages the + /// temporary folder into the installed package location. + /// + /// The single-file package update request. + /// Optional progress reporter for package download status. + /// A token that cancels download, extraction, cleanup, and replacement work. + /// A task that completes after the package folder has been replaced. + public async Task UpdateAsync( + SingleFilePackageUpdateRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentException.ThrowIfNullOrWhiteSpace(request.TemporaryFolderPath); + ArgumentException.ThrowIfNullOrWhiteSpace(request.InstalledFolderPath); + + PackageStagingFolderCleaner.ClearDirectory(request.TemporaryFolderPath, _logger); + _logger.LogInformation( + "Starting single-file package update from {Host}.", + request.SourceUri.Host); + + DownloadFileMetadata metadata = await _metadataReader.ReadMetadataAsync( + request.SourceUri, + cancellationToken).ConfigureAwait(false); + + string destinationFilePath = Path.Combine(request.TemporaryFolderPath, metadata.FileName); + bool extractionRequired = IsArchiveFile(destinationFilePath); + var progressTracker = new PackageProgressTracker(metadata.TotalBytes); + + IProgress downloadProgress = new Progress(report => + { + PackageUpdateProgress? packageProgress = progressTracker.Update( + metadata.FileName, + report.BytesDownloaded, + report.TotalBytes.HasValue && report.BytesDownloaded >= report.TotalBytes.Value); + if (packageProgress is not null) + { + progress?.Report(packageProgress); + } + }); + + await _fileDownloader.DownloadFileAsync( + new DownloadFileRequest( + metadata.DownloadUri, + destinationFilePath, + metadata.TotalBytes, + Resume: true), + downloadProgress, + cancellationToken).ConfigureAwait(false); + + if (extractionRequired) + { + await Task.Run( + () => _archiveExtractor.ExtractToDirectory( + destinationFilePath, + request.TemporaryFolderPath, + new ArchiveExtractionOptions { ConvertBigFilesToGib = true }, + cancellationToken), + cancellationToken).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + if (File.Exists(destinationFilePath)) + { + File.Delete(destinationFilePath); + _logger.LogInformation( + "Deleted downloaded archive {FileName} after extraction.", + Path.GetFileName(destinationFilePath)); + } + } + + cancellationToken.ThrowIfCancellationRequested(); + PackageInstallFolderReplacer.Replace(request.TemporaryFolderPath, request.InstalledFolderPath, _logger); + PackageStagingFolderCleaner.DeleteEmptyPackageParents(request.TemporaryFolderPath, _logger); + _logger.LogInformation("Completed single-file package update."); + } + + private static bool IsArchiveFile(string filePath) + { + string extension = Path.GetExtension(filePath); + return string.Equals(extension, ".zip", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".rar", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".7z", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Services/WindowsSystemClockService.cs b/GenLauncherGO.Infrastructure/Updating/Services/WindowsSystemClockService.cs new file mode 100644 index 00000000..f5151e9a --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/WindowsSystemClockService.cs @@ -0,0 +1,288 @@ +using System; +using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Runtime.InteropServices; +using GenLauncherGO.Core.Updating.Contracts; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Updating.Services; + +/// +/// Checks and adjusts the Windows system clock using NTP network time. +/// +public sealed class WindowsSystemClockService : ISystemClockService +{ + /// + /// Stores the default Windows NTP server used by the legacy launcher. + /// + private const string NtpServer = "time.windows.com"; + + /// + /// Stores the maximum accepted system clock drift before S3 downloads are blocked. + /// + private static readonly TimeSpan _maximumAcceptedClockDrift = TimeSpan.FromMinutes(15); + + /// + /// Receives diagnostics for network time and system clock failures. + /// + private readonly ILogger _logger; + + /// + /// Reads the current time from the configured network time source. + /// + private readonly Func _getNetworkTime; + + /// + /// Reads the current local system time and offset. + /// + private readonly Func _getLocalTime; + + /// + /// Applies a UTC timestamp to the Windows system clock. + /// + private readonly Func _setSystemTimeUtc; + + /// + /// Reads the most recent Win32 error code after a failed native call. + /// + private readonly Func _getLastWin32Error; + + /// + /// Initializes a new instance of the class. + /// + /// The logger used for system clock diagnostics. + public WindowsSystemClockService(ILogger logger) + : this( + logger, + GetNetworkTime, + () => DateTimeOffset.Now, + TrySetSystemTimeUtc, + Marshal.GetLastWin32Error) + { + } + + /// + /// Initializes a new instance of the class with testable clock adapters. + /// + /// The logger used for system clock diagnostics. + /// Reads the current network time. + /// Reads the current local system time and offset. + /// Applies a UTC timestamp to the Windows system clock. + /// Reads the last Win32 error after a failed native call. + internal WindowsSystemClockService( + ILogger logger, + Func getNetworkTime, + Func getLocalTime, + Func setSystemTimeUtc, + Func getLastWin32Error) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _getNetworkTime = getNetworkTime ?? throw new ArgumentNullException(nameof(getNetworkTime)); + _getLocalTime = getLocalTime ?? throw new ArgumentNullException(nameof(getLocalTime)); + _setSystemTimeUtc = setSystemTimeUtc ?? throw new ArgumentNullException(nameof(setSystemTimeUtc)); + _getLastWin32Error = getLastWin32Error ?? throw new ArgumentNullException(nameof(getLastWin32Error)); + } + + /// + public bool IsSystemTimeOutOfSync() + { + try + { + DateTime networkTime = _getNetworkTime(); + if (networkTime == default) + { + return false; + } + + TimeSpan drift = networkTime - _getLocalTime().DateTime; + return drift >= _maximumAcceptedClockDrift || drift <= -_maximumAcceptedClockDrift; + } + catch (Exception exception) when (exception is SocketException or IOException) + { + _logger.LogWarning(exception, "Failed to compare system time with network time."); + return false; + } + } + + /// + public bool TrySynchronizeSystemTimeWithNetworkTime() + { + try + { + DateTime networkTime = _getNetworkTime(); + if (networkTime == default) + { + _logger.LogWarning("Network time returned an empty timestamp; system clock was not changed."); + return false; + } + + DateTimeOffset localOffset = _getLocalTime(); + DateTime utcNetworkTime = networkTime.Subtract(localOffset.Offset); + + bool updated = _setSystemTimeUtc(utcNetworkTime); + if (!updated) + { + int errorCode = _getLastWin32Error(); + _logger.LogWarning( + "Failed to update Windows system time. Win32 error: {Win32ErrorCode}.", + errorCode); + } + + return updated; + } + catch (Exception exception) when (exception is SocketException or IOException or Win32Exception) + { + _logger.LogWarning(exception, "Failed to synchronize system time with network time."); + return false; + } + } + + /// + /// Retrieves the current time from the default Windows time server. + /// + /// The network time converted to local time, or for an empty response. + [ExcludeFromCodeCoverage(Justification = "Performs a live UDP NTP request; response parsing is covered separately.")] + private static DateTime GetNetworkTime() + { + byte[] ntpData = new byte[48]; + ntpData[0] = 0x1B; + + IPAddress[] addresses = Dns.GetHostEntry(NtpServer).AddressList; + IPEndPoint ipEndPoint = new(addresses[0], 123); + + using Socket socket = new(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + socket.Connect(ipEndPoint); + socket.ReceiveTimeout = 3000; + socket.Send(ntpData); + socket.Receive(ntpData); + + return ConvertNtpResponseToLocalTime(ntpData); + } + + /// + /// Converts an NTP response packet to local system time. + /// + /// The NTP response packet. + /// The response timestamp converted to local time, or for an empty response. + internal static DateTime ConvertNtpResponseToLocalTime(byte[] ntpData) + { + ArgumentNullException.ThrowIfNull(ntpData); + + const byte serverReplyTime = 40; + ulong intPart = BitConverter.ToUInt32(ntpData, serverReplyTime); + ulong fractionPart = BitConverter.ToUInt32(ntpData, serverReplyTime + 4); + intPart = SwapEndianness(intPart); + fractionPart = SwapEndianness(fractionPart); + + ulong milliseconds = intPart * 1000 + fractionPart * 1000 / 0x100000000L; + if (milliseconds == 0) + { + return default; + } + + DateTime networkDateTime = new DateTime( + 1900, + 1, + 1, + 0, + 0, + 0, + DateTimeKind.Utc).AddMilliseconds((long)milliseconds); + + return networkDateTime.ToLocalTime(); + } + + /// + /// Converts a 32-bit unsigned integer from big-endian to little-endian. + /// + /// The big-endian value. + /// The little-endian value. + private static uint SwapEndianness(ulong value) + { + return (uint)(((value & 0x000000ff) << 24) + + ((value & 0x0000ff00) << 8) + + ((value & 0x00ff0000) >> 8) + + ((value & 0xff000000) >> 24)); + } + + /// + /// Applies a UTC timestamp to the Windows system clock through the native API. + /// + /// The UTC network time to apply. + /// when the native call updates the Windows clock. + [ExcludeFromCodeCoverage(Justification = "Calls the Windows SetSystemTime API; synchronization behavior is covered through the injected adapter.")] + private static bool TrySetSystemTimeUtc(DateTime utcNetworkTime) + { + SystemTime systemTime = new() + { + Year = Convert.ToInt16(utcNetworkTime.Year), + Month = Convert.ToInt16(utcNetworkTime.Month), + DayOfWeek = 0, + Day = Convert.ToInt16(utcNetworkTime.Day), + Hour = Convert.ToInt16(utcNetworkTime.Hour), + Minute = Convert.ToInt16(utcNetworkTime.Minute), + Second = Convert.ToInt16(utcNetworkTime.Second), + Milliseconds = 0, + }; + + return SetSystemTime(ref systemTime); + } + + /// + /// Sets the Windows system clock to the provided UTC time. + /// + /// The UTC system time to apply. + /// when the Windows API updates the clock. + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool SetSystemTime(ref SystemTime systemTime); + + /// + /// Represents the unmanaged Windows SYSTEMTIME structure used by SetSystemTime. + /// + [StructLayout(LayoutKind.Sequential)] + private struct SystemTime + { + /// + /// The UTC year. + /// + internal short Year; + + /// + /// The UTC month. + /// + internal short Month; + + /// + /// The UTC day of week. This value is ignored by SetSystemTime. + /// + internal short DayOfWeek; + + /// + /// The UTC day. + /// + internal short Day; + + /// + /// The UTC hour. + /// + internal short Hour; + + /// + /// The UTC minute. + /// + internal short Minute; + + /// + /// The UTC second. + /// + internal short Second; + + /// + /// The UTC millisecond. + /// + internal short Milliseconds; + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/BigFileVariantPath.cs b/GenLauncherGO.Infrastructure/Updating/Support/BigFileVariantPath.cs new file mode 100644 index 00000000..290b4bf5 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/BigFileVariantPath.cs @@ -0,0 +1,76 @@ +using System; +using System.IO; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +/// +/// Resolves the installed and in-progress path variants for package .big files. +/// +internal static class BigFileVariantPath +{ + /// + /// Returns the existing downloaded path, preferring the requested path and then the converted .gib path. + /// + /// The requested destination file path. + /// The existing file path, or an empty string when neither variant exists. + public static string GetExistingDownloadedPath(string destinationFilePath) + { + if (File.Exists(destinationFilePath)) + { + return destinationFilePath; + } + + string gibFilePath = Path.ChangeExtension(destinationFilePath, ".gib"); + return File.Exists(gibFilePath) ? gibFilePath : string.Empty; + } + + /// + /// Converts a downloaded .big file to its installed .gib path. + /// + /// The downloaded file path. + public static void ConvertBigFileToGib(string destinationFilePath) + { + if (!IsBigFilePath(destinationFilePath)) + { + return; + } + + string gibFilePath = Path.ChangeExtension(destinationFilePath, ".gib"); + if (File.Exists(gibFilePath)) + { + File.Delete(gibFilePath); + } + + File.Move(destinationFilePath, gibFilePath); + } + + /// + /// Moves an existing .gib file back to .big so a resumed download can append to it. + /// + /// The requested download destination path. + public static void PrepareBigFileResumePath(string destinationFilePath) + { + if (!IsBigFilePath(destinationFilePath)) + { + return; + } + + string gibFilePath = Path.ChangeExtension(destinationFilePath, ".gib"); + if (!File.Exists(gibFilePath) || File.Exists(destinationFilePath)) + { + return; + } + + File.Move(gibFilePath, destinationFilePath); + } + + /// + /// Returns whether a path has the .big extension. + /// + /// The file path to inspect. + /// when the path targets a .big file. + private static bool IsBigFilePath(string filePath) + { + return string.Equals(Path.GetExtension(filePath), ".big", StringComparison.OrdinalIgnoreCase); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/DownloadLinkResolver.cs b/GenLauncherGO.Infrastructure/Updating/Support/DownloadLinkResolver.cs new file mode 100644 index 00000000..d6ad768c --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/DownloadLinkResolver.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +/// +/// Resolves legacy catalog share links into direct package download links. +/// +public static class DownloadLinkResolver +{ + /// + /// Resolves a package source link into an absolute download URI. + /// + /// The package source link. + /// The resolved absolute download URI. + public static Uri ResolveDownloadUri(string link) + { + return new Uri(ResolveDirectDownloadLink(link), UriKind.Absolute); + } + + /// + /// Converts supported share links into direct download links. + /// + /// The package source link. + /// The resolved direct download link. + /// + /// Thrown when is missing. + /// + public static string ResolveDirectDownloadLink(string link) + { + if (string.IsNullOrWhiteSpace(link)) + { + throw new ArgumentException( + "Download link is missing from the modification metadata.", + nameof(link)); + } + + if (link.Contains("www.dropbox.com", StringComparison.Ordinal)) + { + link = link.Replace("?dl=0", "?dl=1"); + } + + if (link.Contains("https://onedrive.live.com", StringComparison.Ordinal)) + { + link = ResolveOneDriveLink(link); + } + + return link; + } + + /// + /// Converts a supported OneDrive share or embed link to a direct download link. + /// + /// The OneDrive link. + /// The direct download link. + private static string ResolveOneDriveLink(string link) + { + if (link.Contains("embed", StringComparison.Ordinal)) + { + return link.Replace("embed", "download"); + } + + List linkParts = [.. link.Replace("https://onedrive.live.com/?", string.Empty).Split('&')]; + string? cid = linkParts.Where(t => t.Contains("cid=", StringComparison.Ordinal)) + .Select(t => t.Replace("cid=", string.Empty)) + .FirstOrDefault(); + string? authKey = linkParts.Where(t => t.Contains("authkey=", StringComparison.Ordinal)) + .Select(t => t.Replace("authkey=", string.Empty)) + .FirstOrDefault(); + string? resid = linkParts.Where(t => + t.Contains("id=", StringComparison.Ordinal) && + !t.Contains("cid=", StringComparison.Ordinal)) + .Select(t => t.Replace("id=", string.Empty)) + .FirstOrDefault(); + + return string.Format( + CultureInfo.InvariantCulture, + "https://onedrive.live.com/download?cid={0}&resid={1}&authkey={2}", + cid, + resid, + authKey); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/ManifestPathResolver.cs b/GenLauncherGO.Infrastructure/Updating/Support/ManifestPathResolver.cs new file mode 100644 index 00000000..b206cb84 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/ManifestPathResolver.cs @@ -0,0 +1,93 @@ +using System; +using System.IO; +using GenLauncherGO.Infrastructure.Common; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +/// +/// Resolves manifest-provided relative paths while preventing writes outside the package root. +/// +internal static class ManifestPathResolver +{ + /// + /// Resolves a manifest file name to a full path under the specified root directory. + /// + /// The root directory that must contain the resolved path. + /// The manifest-provided relative file name. + /// The fully qualified path for the manifest file. + /// + /// Thrown when the root directory or manifest file name is empty, rooted, drive-qualified, or contains a parent + /// directory segment. + /// + /// + /// Thrown when the resolved path would leave . + /// + public static string ResolvePath(string rootDirectory, string manifestFileName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(rootDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(manifestFileName); + + string normalizedFileName = NormalizeRelativePath(manifestFileName); + string rootPath = Path.GetFullPath(rootDirectory); + string candidatePath = Path.GetFullPath(Path.Combine(rootPath, normalizedFileName)); + + if (!FileSystemPathSafety.IsPathInDirectory(candidatePath, rootPath)) + { + throw new InvalidDataException( + $"Manifest file '{manifestFileName}' would resolve outside the package directory."); + } + + return candidatePath; + } + + /// + /// Normalizes a manifest path to the current platform directory separator after validation. + /// + /// The manifest-provided relative file name. + /// The normalized relative file name. + /// + /// Thrown when the path is rooted, drive-qualified, empty, or contains a parent directory segment. + /// + public static string NormalizeRelativePath(string manifestFileName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(manifestFileName); + + string trimmedFileName = manifestFileName.Trim(); + if (Path.IsPathRooted(trimmedFileName) || + trimmedFileName.Contains(':', StringComparison.Ordinal)) + { + throw new ArgumentException("Manifest file paths must be relative.", nameof(manifestFileName)); + } + + string[] segments = trimmedFileName.Split( + new[] { '/', '\\' }, + StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) + { + throw new ArgumentException("Manifest file paths must include a file name.", nameof(manifestFileName)); + } + + foreach (string segment in segments) + { + if (string.Equals(segment, ".", StringComparison.Ordinal) || + string.Equals(segment, "..", StringComparison.Ordinal)) + { + throw new ArgumentException( + "Manifest file paths must not contain current or parent directory segments.", + nameof(manifestFileName)); + } + } + + return Path.Combine(segments); + } + + /// + /// Normalizes a manifest path to slash separators for manifest index lookups. + /// + /// The manifest-provided relative file name. + /// The normalized slash-separated manifest path. + public static string NormalizeForManifestIndex(string manifestFileName) + { + return FileSystemPathSafety.NormalizeRelativePath(NormalizeRelativePath(manifestFileName)); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/PackageInstallFolderReplacer.cs b/GenLauncherGO.Infrastructure/Updating/Support/PackageInstallFolderReplacer.cs new file mode 100644 index 00000000..ba39cb2d --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/PackageInstallFolderReplacer.cs @@ -0,0 +1,190 @@ +using System; +using System.Globalization; +using System.IO; +using GenLauncherGO.Infrastructure.Common; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +/// +/// Replaces an installed package folder through a staged move with rollback support. +/// +internal static class PackageInstallFolderReplacer +{ + /// + /// Replaces an installed folder with a temporary folder without deleting the existing install first. + /// + /// The prepared temporary package folder. + /// The final installed package folder. + /// The logger used for replacement diagnostics. + /// + /// Thrown when or is empty or + /// whitespace. + /// + /// + /// Thrown when does not exist. + /// + /// + /// Thrown when folder replacement or rollback cannot be completed. + /// + public static void Replace( + string temporaryFolderPath, + string installedFolderPath, + ILogger logger) + { + ArgumentException.ThrowIfNullOrWhiteSpace(temporaryFolderPath); + ArgumentException.ThrowIfNullOrWhiteSpace(installedFolderPath); + ArgumentNullException.ThrowIfNull(logger); + + string temporaryPath = Path.GetFullPath(temporaryFolderPath); + string installedPath = Path.GetFullPath(installedFolderPath); + if (!Directory.Exists(temporaryPath)) + { + throw new DirectoryNotFoundException($"Temporary package folder '{temporaryFolderPath}' was not found."); + } + + EnsureDirectoryPathHasNoReparsePoints( + temporaryPath, + "Temporary package paths must be rooted.", + "The temporary package folder cannot contain a reparse point.", + logger); + EnsureDirectoryPathHasNoReparsePoints( + installedPath, + "Installed package paths must be rooted.", + "The installed package folder cannot contain a reparse point.", + logger); + + string? parentDirectory = Path.GetDirectoryName(installedPath); + if (!string.IsNullOrWhiteSpace(parentDirectory)) + { + Directory.CreateDirectory(parentDirectory); + } + + string backupPath = CreateBackupPath(installedPath); + bool backupCreated = false; + try + { + if (Directory.Exists(installedPath)) + { + logger.LogInformation( + "Moving existing installed package folder {InstalledFolderName} to a staged backup.", + Path.GetFileName(installedPath)); + Directory.Move(installedPath, backupPath); + backupCreated = true; + } + + logger.LogInformation( + "Moving temporary package folder {TemporaryFolderName} into installed package location {InstalledFolderName}.", + Path.GetFileName(temporaryPath), + Path.GetFileName(installedPath)); + Directory.Move(temporaryPath, installedPath); + + if (backupCreated) + { + Directory.Delete(backupPath, recursive: true); + } + } + catch + { + RollBackReplacement(temporaryPath, installedPath, backupPath, backupCreated, logger); + throw; + } + } + + /// + /// Verifies that an install or staging directory path does not cross or contain links before replacement. + /// + /// The directory path to inspect. + /// The exception message used when the path is not rooted. + /// The exception message used when a reparse point is found. + /// The logger used for replacement diagnostics. + private static void EnsureDirectoryPathHasNoReparsePoints( + string directoryPath, + string unrootedPathMessage, + string linkedPathMessage, + ILogger logger) + { + try + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + directoryPath, + unrootedPathMessage, + linkedPathMessage); + if (Directory.Exists(directoryPath)) + { + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints(directoryPath, linkedPathMessage); + } + } + catch (InvalidDataException ex) + { + logger.LogWarning( + ex, + "Blocked package folder replacement because {FolderName} contains a reparse point.", + Path.GetFileName(directoryPath)); + throw new IOException(linkedPathMessage, ex); + } + } + + /// + /// Creates a unique backup path next to the installed folder. + /// + /// The fully qualified installed folder path. + /// A non-existing backup folder path. + private static string CreateBackupPath(string installedPath) + { + string parentDirectory = Path.GetDirectoryName(installedPath) ?? "."; + string folderName = Path.GetFileName(installedPath); + string timestamp = DateTimeOffset.UtcNow.ToString("yyyyMMddHHmmssfff", CultureInfo.InvariantCulture); + + for (int attempt = 0; attempt < 100; attempt++) + { + string suffix = attempt == 0 + ? timestamp + : string.Create(CultureInfo.InvariantCulture, $"{timestamp}-{attempt}"); + string backupPath = Path.Combine(parentDirectory, $"{folderName}.backup-{suffix}"); + if (!Directory.Exists(backupPath)) + { + return backupPath; + } + } + + return Path.Combine(parentDirectory, $"{folderName}.backup-{Guid.NewGuid():N}"); + } + + /// + /// Attempts to restore the prior installed folder after a staged replacement failure. + /// + /// The temporary package folder path. + /// The installed package folder path. + /// The staged backup folder path. + /// A value indicating whether a backup folder was created. + /// The logger used for rollback diagnostics. + private static void RollBackReplacement( + string temporaryPath, + string installedPath, + string backupPath, + bool backupCreated, + ILogger logger) + { + if (!backupCreated || !Directory.Exists(backupPath) || Directory.Exists(installedPath)) + { + return; + } + + try + { + logger.LogWarning( + "Rolling back package folder replacement for {InstalledFolderName}.", + Path.GetFileName(installedPath)); + Directory.Move(backupPath, installedPath); + } + catch (Exception ex) + { + logger.LogError( + ex, + "Failed to roll back package folder replacement for {InstalledFolderName}. Temporary folder exists: {TemporaryFolderExists}", + Path.GetFileName(installedPath), + Directory.Exists(temporaryPath)); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/PackageProgressTracker.cs b/GenLauncherGO.Infrastructure/Updating/Support/PackageProgressTracker.cs new file mode 100644 index 00000000..b5a4f4bc --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/PackageProgressTracker.cs @@ -0,0 +1,115 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +/// +/// Tracks aggregate package update progress, speed, and estimated time remaining. +/// +internal sealed class PackageProgressTracker +{ + private static readonly TimeSpan _reportInterval = TimeSpan.FromMilliseconds(100); + + private readonly Dictionary _itemProgressBytes = new(StringComparer.OrdinalIgnoreCase); + private readonly object _progressGate = new(); + private readonly Stopwatch _stopwatch = Stopwatch.StartNew(); + + private TimeSpan _lastReportElapsed; + private long _lastReportedBytesRead; + private long? _totalBytes; + private long _totalBytesRead; + + /// + /// Initializes a new instance of the class. + /// + /// The expected package byte count when known. + public PackageProgressTracker(long? totalBytes) + { + _totalBytes = totalBytes; + } + + /// + /// Adds expected transfer bytes when a resumed download has to restart from byte zero. + /// + /// The additional expected bytes. + public void AddExpectedBytes(long bytes) + { + if (bytes <= 0) + { + return; + } + + lock (_progressGate) + { + if (_totalBytes.HasValue) + { + _totalBytes += bytes; + } + } + } + + /// + /// Updates an item byte count and returns a throttled aggregate progress report. + /// + /// The internal item name whose byte count changed. + /// The number of bytes completed for the item. + /// A value indicating whether a report should be returned even when throttled. + /// An aggregate progress report, or when the update was throttled. + public PackageUpdateProgress? Update( + string itemName, + long bytesRead, + bool forceReport = false) + { + lock (_progressGate) + { + long previousBytesRead = _itemProgressBytes.TryGetValue(itemName, out long previous) + ? previous + : 0; + long normalizedBytesRead = Math.Max(0, bytesRead); + _itemProgressBytes[itemName] = normalizedBytesRead; + _totalBytesRead += normalizedBytesRead - previousBytesRead; + + TimeSpan elapsed = _stopwatch.Elapsed; + long? totalBytes = _totalBytes; + bool completed = totalBytes.HasValue && _totalBytesRead >= totalBytes.Value; + if (!forceReport && + !completed && + elapsed - _lastReportElapsed < _reportInterval && + _lastReportedBytesRead != 0) + { + return null; + } + + _lastReportElapsed = elapsed; + _lastReportedBytesRead = _totalBytesRead; + + double? progressPercentage = null; + if (totalBytes is > 0) + { + progressPercentage = Math.Round((double)_totalBytesRead / totalBytes.Value * 100, 2); + } + + double? speedBytesPerSecond = null; + TimeSpan? estimatedTimeRemaining = null; + if (elapsed.TotalSeconds > 0.25 && _totalBytesRead > 0) + { + speedBytesPerSecond = _totalBytesRead / elapsed.TotalSeconds; + if (totalBytes.HasValue && speedBytesPerSecond > 0) + { + long remainingBytes = Math.Max(0, totalBytes.Value - _totalBytesRead); + estimatedTimeRemaining = TimeSpan.FromSeconds(remainingBytes / speedBytesPerSecond.Value); + } + } + + return new PackageUpdateProgress( + totalBytes, + _totalBytesRead, + progressPercentage, + null, + speedBytesPerSecond, + estimatedTimeRemaining); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/PackageStagingFolderCleaner.cs b/GenLauncherGO.Infrastructure/Updating/Support/PackageStagingFolderCleaner.cs new file mode 100644 index 00000000..0c45a20d --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/PackageStagingFolderCleaner.cs @@ -0,0 +1,315 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Common; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +/// +/// Cleans package staging folders before they are moved into an installed package location. +/// +internal static class PackageStagingFolderCleaner +{ + /// + /// Deletes all child entries from the staging folder, creating the folder when it does not exist. + /// + /// The staging folder path to empty. + /// The logger used for cleanup diagnostics. + public static void ClearDirectory(string stagingFolderPath, ILogger logger) + { + ArgumentException.ThrowIfNullOrWhiteSpace(stagingFolderPath); + ArgumentNullException.ThrowIfNull(logger); + + string stagingRoot = PrepareSafeRoot(stagingFolderPath, logger); + DeleteChildEntriesSafely(stagingRoot); + + logger.LogInformation( + "Cleared package staging folder {StagingFolderName}.", + Path.GetFileName(stagingRoot)); + } + + /// + /// Deletes empty package staging parent folders after a staged package version folder has been moved into place. + /// + /// The staging folder that was moved into the installed package location. + /// The logger used for cleanup diagnostics. + public static void DeleteEmptyPackageParents(string stagingFolderPath, ILogger logger) + { + ArgumentException.ThrowIfNullOrWhiteSpace(stagingFolderPath); + ArgumentNullException.ThrowIfNull(logger); + + string stagingRoot = Path.GetFullPath(stagingFolderPath); + DirectoryInfo? packagesDirectory = FindPackagesAncestor(stagingRoot); + if (packagesDirectory is null) + { + return; + } + + DirectoryInfo? currentDirectory = Directory.GetParent(stagingRoot); + while (currentDirectory is not null) + { + bool reachedPackagesDirectory = string.Equals( + currentDirectory.FullName, + packagesDirectory.FullName, + StringComparison.OrdinalIgnoreCase); + + if (!currentDirectory.Exists) + { + if (reachedPackagesDirectory) + { + return; + } + + currentDirectory = currentDirectory.Parent; + continue; + } + + if (Directory.EnumerateFileSystemEntries(currentDirectory.FullName).Any()) + { + return; + } + + try + { + Directory.Delete(currentDirectory.FullName); + logger.LogInformation( + "Deleted empty package staging folder {StagingFolderName}.", + currentDirectory.Name); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + logger.LogWarning( + ex, + "Failed to delete empty package staging folder {StagingFolderName}.", + currentDirectory.Name); + return; + } + + if (reachedPackagesDirectory) + { + return; + } + + currentDirectory = currentDirectory.Parent; + } + } + + /// + /// Finds the package staging root in a temporary package folder path. + /// + /// The full staging folder path. + /// The package staging root, or when the path is not a package staging path. + private static DirectoryInfo? FindPackagesAncestor(string stagingFolderPath) + { + DirectoryInfo? currentDirectory = Directory.GetParent(stagingFolderPath); + while (currentDirectory is not null) + { + if (string.Equals(currentDirectory.Name, "Packages", StringComparison.OrdinalIgnoreCase)) + { + return currentDirectory; + } + + currentDirectory = currentDirectory.Parent; + } + + return null; + } + + /// + /// Deletes reparse points from a staging folder without following them, recreating the staging root when it is + /// itself a link. + /// + /// The staging folder to sanitize. + /// The logger used for cleanup diagnostics. + /// A token that cancels cleanup between entries. + public static void RemoveUnsafeLinks( + string stagingFolderPath, + ILogger logger, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(stagingFolderPath); + ArgumentNullException.ThrowIfNull(logger); + + string stagingRoot = PrepareSafeRoot(stagingFolderPath, logger); + RemoveUnsafeChildLinks(stagingRoot, logger, cancellationToken); + } + + /// + /// Deletes staged files that are not expected by the remote manifest. + /// + /// The staging folder to prune. + /// The files expected by the remote manifest. + /// The logger used for cleanup diagnostics. + /// A token that cancels pruning between file-system operations. + public static void PruneToManifest( + string stagingFolderPath, + IReadOnlyList files, + ILogger logger, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(stagingFolderPath); + ArgumentNullException.ThrowIfNull(files); + ArgumentNullException.ThrowIfNull(logger); + + string stagingRoot = Path.GetFullPath(stagingFolderPath); + Directory.CreateDirectory(stagingRoot); + RemoveUnsafeLinks(stagingRoot, logger, cancellationToken); + + HashSet expectedPaths = BuildExpectedInstalledPaths(stagingRoot, files); + foreach (string filePath in Directory + .EnumerateFiles(stagingRoot, "*", FileSystemPathSafety.CreateRecursiveNoLinksOptions()) + .ToList()) + { + cancellationToken.ThrowIfCancellationRequested(); + + string fullPath = Path.GetFullPath(filePath); + if (expectedPaths.Contains(fullPath)) + { + continue; + } + + File.Delete(fullPath); + logger.LogInformation( + "Deleted stale staged package file {FileName}.", + Path.GetFileName(fullPath)); + } + + foreach (string directoryPath in Directory + .EnumerateDirectories(stagingRoot, "*", FileSystemPathSafety.CreateRecursiveNoLinksOptions()) + .OrderByDescending(path => path.Length) + .ToList()) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!Directory.EnumerateFileSystemEntries(directoryPath).Any()) + { + Directory.Delete(directoryPath); + } + } + } + + /// + /// Builds the set of final staged file paths expected from a manifest. + /// + /// The fully qualified staging root. + /// The manifest entries to resolve. + /// The expected full staged file paths. + private static HashSet BuildExpectedInstalledPaths( + string stagingRoot, + IReadOnlyList files) + { + HashSet expectedPaths = new(StringComparer.OrdinalIgnoreCase); + foreach (RemoteFileManifestEntry file in files) + { + string destinationPath = ManifestPathResolver.ResolvePath(stagingRoot, file.FileName); + if (string.Equals(Path.GetExtension(destinationPath), ".big", StringComparison.OrdinalIgnoreCase)) + { + destinationPath = Path.ChangeExtension(destinationPath, ".gib"); + } + + expectedPaths.Add(Path.GetFullPath(destinationPath)); + } + + return expectedPaths; + } + + /// + /// Ensures a staging root is a real directory rather than a reparse point. + /// + /// The staging folder path. + /// The logger used for cleanup diagnostics. + /// The fully qualified safe staging root. + private static string PrepareSafeRoot(string stagingFolderPath, ILogger logger) + { + string stagingRoot = Path.GetFullPath(stagingFolderPath); + if (Directory.Exists(stagingRoot) && FileSystemPathSafety.IsReparsePoint(stagingRoot)) + { + DeleteEntryWithoutFollowing(stagingRoot); + logger.LogWarning( + "Removed unsafe staging-root link {StagingFolderName}.", + Path.GetFileName(stagingRoot)); + } + + Directory.CreateDirectory(stagingRoot); + return stagingRoot; + } + + /// + /// Recursively deletes all child entries without traversing reparse points. + /// + /// The real directory whose children will be deleted. + private static void DeleteChildEntriesSafely(string directory) + { + foreach (string entry in Directory.EnumerateFileSystemEntries(directory).ToList()) + { + FileAttributes attributes = File.GetAttributes(entry); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + DeleteEntryWithoutFollowing(entry); + continue; + } + + if ((attributes & FileAttributes.Directory) != 0) + { + DeleteChildEntriesSafely(entry); + Directory.Delete(entry); + continue; + } + + File.Delete(entry); + } + } + + /// + /// Recursively removes unsafe child links without traversing them. + /// + /// The real directory to inspect. + /// The logger used for cleanup diagnostics. + /// A token that cancels cleanup between entries. + private static void RemoveUnsafeChildLinks( + string directory, + ILogger logger, + CancellationToken cancellationToken) + { + foreach (string entry in Directory.EnumerateFileSystemEntries(directory).ToList()) + { + cancellationToken.ThrowIfCancellationRequested(); + FileAttributes attributes = File.GetAttributes(entry); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + DeleteEntryWithoutFollowing(entry); + logger.LogWarning( + "Removed unsafe staging link {EntryName}.", + Path.GetFileName(entry)); + continue; + } + + if ((attributes & FileAttributes.Directory) != 0) + { + RemoveUnsafeChildLinks(entry, logger, cancellationToken); + } + } + } + + /// + /// Deletes one file, directory, or link without recursively following it. + /// + /// The entry to delete. + private static void DeleteEntryWithoutFollowing(string path) + { + FileAttributes attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.Directory) != 0) + { + Directory.Delete(path, recursive: false); + } + else + { + File.Delete(path); + } + } + +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/S3CatalogDefaults.cs b/GenLauncherGO.Infrastructure/Updating/Support/S3CatalogDefaults.cs new file mode 100644 index 00000000..5c0c8ffd --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/S3CatalogDefaults.cs @@ -0,0 +1,80 @@ +using System; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +/// +/// Provides S3-compatible catalog defaults used by legacy remote modification metadata. +/// +/// +/// These values are retained for compatibility with the original GenLauncher client and backend. The original client +/// already shipped them in client-side code before this GenLauncherGO rewrite/fork, so this project treats them as +/// public legacy credentials rather than private application secrets. The backend/object-storage policy must assume +/// every user can read these values and must keep their permissions limited accordingly. +/// +public static class S3CatalogDefaults +{ + /// + /// Gets the default public S3 access key used when catalog metadata does not provide one. + /// + /// + /// This legacy value was already exposed by the original client and is kept only so old catalog entries continue + /// to resolve. + /// + public const string PublicAccessKey = "S58TYR9ISEZV8PBP8QG1"; + + /// + /// Gets the default public S3 secret key used when catalog metadata does not provide one. + /// + /// + /// This legacy value was already exposed by the original client. Do not replace it with a privileged secret unless + /// downloads are moved behind a trusted backend or another non-client-side credential flow. + /// + public const string PublicSecretKey = "b2RU1oqVU5toJRnb4gODrXX8sBSgoLcHRX6qPWxj"; + + /// + /// Creates a manifest request from one remote modification version. + /// + /// The remote modification version metadata. + /// The S3-compatible object manifest request. + public static S3ObjectManifestRequest CreateManifestRequest(ModificationVersion version) + { + ArgumentNullException.ThrowIfNull(version); + + return new S3ObjectManifestRequest( + version.S3HostLink, + version.S3BucketName, + version.S3FolderName, + ResolveAccessKey(version), + ResolveSecretKey(version)); + } + + /// + /// Resolves the access key for a modification version, falling back to the public catalog key. + /// + /// The remote modification version metadata. + /// The resolved S3 access key. + public static string ResolveAccessKey(ModificationVersion version) + { + ArgumentNullException.ThrowIfNull(version); + + return string.IsNullOrEmpty(version.S3HostPublicKey) + ? PublicAccessKey + : version.S3HostPublicKey; + } + + /// + /// Resolves the secret key for a modification version, falling back to the public catalog key. + /// + /// The remote modification version metadata. + /// The resolved S3 secret key. + public static string ResolveSecretKey(ModificationVersion version) + { + ArgumentNullException.ThrowIfNull(version); + + return string.IsNullOrEmpty(version.S3HostSecretKey) + ? PublicSecretKey + : version.S3HostSecretKey; + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/S3HashValidationPolicy.cs b/GenLauncherGO.Infrastructure/Updating/Support/S3HashValidationPolicy.cs new file mode 100644 index 00000000..9f173283 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/S3HashValidationPolicy.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +/// +/// Determines when S3 package files should be validated with reliable MD5 hashes. +/// +internal static class S3HashValidationPolicy +{ + /// + /// Returns whether a manifest entry should be validated with MD5. + /// + /// The manifest file entry. + /// Extensions that require hash validation. + /// when the file extension and hash are suitable for validation. + public static bool ShouldCheckHash( + RemoteFileManifestEntry file, + IReadOnlySet hashCheckedExtensions) + { + return hashCheckedExtensions.Contains(Path.GetExtension(file.FileName)) && + IsReliableMd5Hash(file.Hash); + } + + /// + /// Returns whether a manifest hash is a plain 32-character hexadecimal MD5 value. + /// + /// The hash value to inspect. + /// when the hash is a reliable MD5 value. + public static bool IsReliableMd5Hash(string hash) + { + if (hash.Length != 32) + { + return false; + } + + foreach (char character in hash) + { + bool isHexDigit = character is >= '0' and <= '9' or >= 'a' and <= 'f' or >= 'A' and <= 'F'; + if (!isHexDigit) + { + return false; + } + } + + return true; + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/S3ReusablePackageFileCopier.cs b/GenLauncherGO.Infrastructure/Updating/Support/S3ReusablePackageFileCopier.cs new file mode 100644 index 00000000..32ee203d --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/S3ReusablePackageFileCopier.cs @@ -0,0 +1,213 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +/// +/// Copies unchanged files from the latest installed S3 package into a staging folder. +/// +internal sealed class S3ReusablePackageFileCopier +{ + /// + /// The hash service used to verify reusable files. + /// + private readonly IFileHashService _fileHashService; + + /// + /// The logger used for package reuse diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The hash service used to verify reusable files. + /// The logger used for package reuse diagnostics. + public S3ReusablePackageFileCopier( + IFileHashService fileHashService, + ILogger logger) + { + _fileHashService = fileHashService ?? throw new ArgumentNullException(nameof(fileHashService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Copies unchanged files from a previous installed package into the staging folder. + /// + /// The previous installed package directory. + /// The staging directory. + /// The remote repository manifest entries. + /// A token that cancels hashing and copy work. + /// A task that completes after all reusable files have been staged. + public async Task CopyUnchangedFilesAsync( + string sourceDir, + string destinationDir, + IReadOnlyList repositoryFiles, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(sourceDir); + ArgumentException.ThrowIfNullOrWhiteSpace(destinationDir); + ArgumentNullException.ThrowIfNull(repositoryFiles); + + Dictionary repositoryFileIndex = BuildRepositoryFileIndex(repositoryFiles); + await CopyReusableDirectoryContentAsync( + sourceDir, + destinationDir, + true, + repositoryFileIndex, + string.Empty, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Builds a manifest lookup keyed by normalized relative paths and converted .gib aliases. + /// + /// The remote repository manifest entries. + /// A case-insensitive manifest lookup. + private static Dictionary BuildRepositoryFileIndex( + IReadOnlyList repositoryFiles) + { + Dictionary repositoryFileIndex = + new(StringComparer.OrdinalIgnoreCase); + + foreach (RemoteFileManifestEntry repositoryFile in repositoryFiles) + { + string normalizedPath = ManifestPathResolver.NormalizeForManifestIndex(repositoryFile.FileName); + repositoryFileIndex[normalizedPath] = repositoryFile; + + if (string.Equals(Path.GetExtension(normalizedPath), ".big", StringComparison.OrdinalIgnoreCase)) + { + repositoryFileIndex[Path.ChangeExtension(normalizedPath, ".gib")] = repositoryFile; + } + } + + return repositoryFileIndex; + } + + /// + /// Recursively copies files that match the remote manifest by size and reliable hash. + /// + /// The source directory to inspect. + /// The destination directory to populate. + /// A value indicating whether subdirectories should be inspected. + /// The manifest lookup used to validate reusable files. + /// The manifest-relative path prefix for . + /// A token that cancels hashing and copy work. + /// A task that completes after the current directory has been inspected. + private async Task CopyReusableDirectoryContentAsync( + string sourceDir, + string destinationDir, + bool recursive, + Dictionary repositoryFileIndex, + string pathAddition, + CancellationToken cancellationToken) + { + DirectoryInfo directory = new(sourceDir); + if ((directory.Attributes & FileAttributes.ReparsePoint) != 0) + { + _logger.LogWarning( + "Skipped unsafe reusable package directory link {DirectoryName}.", + directory.Name); + return; + } + + DirectoryInfo[] directories = directory.GetDirectories() + .Where(subdirectory => (subdirectory.Attributes & FileAttributes.ReparsePoint) == 0) + .ToArray(); + + Directory.CreateDirectory(destinationDir); + + foreach (FileInfo file in directory.GetFiles()) + { + cancellationToken.ThrowIfCancellationRequested(); + if ((file.Attributes & FileAttributes.ReparsePoint) != 0) + { + _logger.LogWarning( + "Skipped unsafe reusable package file link {FileName}.", + file.Name); + continue; + } + + await CopyReusableFileAsync( + file, + destinationDir, + repositoryFileIndex, + pathAddition, + cancellationToken).ConfigureAwait(false); + } + + if (!recursive) + { + return; + } + + foreach (DirectoryInfo subDir in directories) + { + cancellationToken.ThrowIfCancellationRequested(); + + await CopyReusableDirectoryContentAsync( + subDir.FullName, + Path.Combine(destinationDir, subDir.Name), + true, + repositoryFileIndex, + ManifestPathResolver.NormalizeForManifestIndex(Path.Combine(pathAddition, subDir.Name)), + cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Copies one reusable file after size and hash validation. + /// + /// The source file to inspect. + /// The destination directory to populate. + /// The manifest lookup used to validate reusable files. + /// The manifest-relative path prefix for . + /// A token that cancels hashing and copy work. + /// A task that completes after the file has been inspected. + private async Task CopyReusableFileAsync( + FileInfo file, + string destinationDir, + Dictionary repositoryFileIndex, + string pathAddition, + CancellationToken cancellationToken) + { + string targetFilePath = ManifestPathResolver.ResolvePath(destinationDir, file.Name); + ulong fileSize = (ulong)file.Length; + string relativeFilePath = ManifestPathResolver.NormalizeForManifestIndex( + Path.Combine(pathAddition, file.Name)); + + if (File.Exists(targetFilePath) || + !repositoryFileIndex.TryGetValue(relativeFilePath, out RemoteFileManifestEntry? repositoryFile) || + repositoryFile.Size != fileSize || + !S3HashValidationPolicy.IsReliableMd5Hash(repositoryFile.Hash)) + { + return; + } + + string hash = await _fileHashService + .ComputeMd5HashAsync(file.FullName, cancellationToken) + .ConfigureAwait(false); + if (!string.Equals(hash, repositoryFile.Hash, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + Directory.CreateDirectory(Path.GetDirectoryName(targetFilePath) ?? destinationDir); + await using FileStream sourceStream = file.OpenRead(); + await using FileStream destinationStream = new( + targetFilePath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.Read, + 1024 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + await sourceStream.CopyToAsync(destinationStream, cancellationToken).ConfigureAwait(false); + } +} diff --git a/GenLauncherGO.Tests/AGENTS.md b/GenLauncherGO.Tests/AGENTS.md new file mode 100644 index 00000000..8d1db921 --- /dev/null +++ b/GenLauncherGO.Tests/AGENTS.md @@ -0,0 +1,32 @@ +# GenLauncherGO.Tests Agent Guidelines + +`GenLauncherGO.Tests/` owns automated tests for application behavior. + +## Preferred Stack + +- xUnit +- FluentAssertions +- NSubstitute +- NSubstitute.Analyzers.CSharp + +## Organization + +- `Core/` for pure model, validation, and workflow contract tests. +- `Infrastructure/` for adapter tests using temp folders or mocked external services. +- `UI/` for WPF/view-model tests when practical. +- `Testing/` for shared builders, fakes, fixtures, and temp-directory helpers. +- Mirror production feature subfolders inside the boundary when a test area grows or the production files already use + layer folders, such as `Core/Launching/Models`, `Infrastructure/Updating/Clients`, or + `Infrastructure/Updating/Services`. + +## Rules + +- Use Arrange, Act, Assert sections. +- Keep Core tests independent of WPF, disk, network, and real game installs. +- Use fake Core interfaces for workflow tests. +- Use temporary directories for infrastructure file-system tests. +- Skip or isolate symbolic-link tests when the environment does not support them. +- Do not use Moq, NMock, or another mocking framework unless a specific test case has a clear limitation that + NSubstitute cannot handle. +- Prefer hand-written fakes for stateful infrastructure behavior when they are clearer than mock setup. +- Keep test package references versionless and package versions centralized in `Directory.Packages.props`. diff --git a/GenLauncherGO.Tests/Core/.gitkeep b/GenLauncherGO.Tests/Core/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/GenLauncherGO.Tests/Core/.gitkeep @@ -0,0 +1 @@ + diff --git a/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityReportTests.cs b/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityReportTests.cs new file mode 100644 index 00000000..707d1dbf --- /dev/null +++ b/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityReportTests.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Tests.Core.Integrity.Models; + +public sealed class ContentIntegrityReportTests +{ + [Fact] + public void ConstructorDefensivelyCopiesIssues() + { + // Arrange + List issues = new() + { + new ContentIntegrityIssue( + "target", + "Target", + ContentSourceKind.ManagedS3, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.Repair, + "file.bin"), + }; + + // Act + ContentIntegrityReport report = new(issues); + issues.Clear(); + + // Assert + report.Issues.Should().ContainSingle(); + } + + [Fact] + public void ConstructorThrowsForNullIssues() + { + // Act + Action act = () => new ContentIntegrityReport(null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void IssueFlagsReflectContainedIssueKinds() + { + // Arrange + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + "managed", + "Managed", + ContentSourceKind.ManagedSingleFile, + IntegrityIssueKind.MissingFile, + IntegrityIssueAction.Repair, + "managed.big"), + new ContentIntegrityIssue( + "manual", + "Manual", + ContentSourceKind.Manual, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.Absorb, + "manual.big"), + new ContentIntegrityIssue( + "legacy", + "Legacy", + ContentSourceKind.UnknownLegacy, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.TrustAsManual, + "legacy.big"), + new ContentIntegrityIssue( + "blocking", + "Blocking", + ContentSourceKind.UnknownLegacy, + IntegrityIssueKind.VerificationError, + IntegrityIssueAction.Block, + "."), + }); + + // Assert + report.HasIssues.Should().BeTrue(); + report.HasManagedIssues.Should().BeTrue(); + report.HasManualIssues.Should().BeTrue(); + report.HasUnknownLegacyIssues.Should().BeTrue(); + report.HasBlockingIssues.Should().BeTrue(); + } + + [Fact] + public void EmptyReportHasNoIssueFlags() + { + // Arrange + ContentIntegrityReport report = new(Array.Empty()); + + // Assert + report.HasIssues.Should().BeFalse(); + report.HasManagedIssues.Should().BeFalse(); + report.HasManualIssues.Should().BeFalse(); + report.HasUnknownLegacyIssues.Should().BeFalse(); + report.HasBlockingIssues.Should().BeFalse(); + } +} diff --git a/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityTargetTests.cs b/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityTargetTests.cs new file mode 100644 index 00000000..69f4baf5 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityTargetTests.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Tests.Core.Integrity.Models; + +public sealed class ContentIntegrityTargetTests +{ + [Fact] + public void ConstructorDefensivelyCopiesIgnoredPaths() + { + // Arrange + HashSet ignoredPaths = new(StringComparer.OrdinalIgnoreCase) + { + "inactive.png", + }; + + // Act + ContentIntegrityTarget target = new( + "target", + "Target", + "content", + ContentSourceKind.ManagedS3, + ignoredPaths); + ignoredPaths.Clear(); + + // Assert + target.IgnoredRelativePaths.Should().Contain("inactive.png"); + } +} diff --git a/GenLauncherGO.Tests/Core/Launching/Models/DeploymentContractTests.cs b/GenLauncherGO.Tests/Core/Launching/Models/DeploymentContractTests.cs new file mode 100644 index 00000000..d02d5b56 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Launching/Models/DeploymentContractTests.cs @@ -0,0 +1,369 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Tests.Core.Launching.Models; + +public sealed class DeploymentContractTests +{ + [Theory] + [InlineData("id")] + [InlineData("displayName")] + [InlineData("rootDirectory")] + public void DeploymentPackageThrowsForMissingRequiredStrings(string missingField) + { + // Arrange + string id = missingField == "id" ? " " : "package-id"; + string displayName = missingField == "displayName" ? " " : "Package"; + string rootDirectory = missingField == "rootDirectory" ? " " : @"C:\Packages\Package"; + + // Act + Action act = () => new DeploymentPackage( + id, + displayName, + DeploymentPackageKind.Mod, + rootDirectory, + 1); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void DeploymentRequestThrowsForMissingPaths() + { + // Arrange + IReadOnlyList packages = new[] { CreatePackage() }; + + // Act + Action act = () => new DeploymentRequest(null!, packages); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void DeploymentRequestThrowsForMissingPackages() + { + // Arrange + LauncherPaths paths = CreatePaths(); + + // Act + Action act = () => new DeploymentRequest(paths, null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void DeploymentRequestStoresDistinctDisabledTargetPaths() + { + // Arrange + LauncherPaths paths = CreatePaths(); + IReadOnlyList packages = new[] { CreatePackage() }; + + // Act + DeploymentRequest request = new( + paths, + packages, + new[] { @"Data\Scripts\Scripts.ini", "Data/Scripts/Scripts.ini", " " }); + + // Assert + request.DisabledTargetRelativePaths.Should().ContainSingle().Which.Should().Be("Data/Scripts/Scripts.ini"); + } + + [Fact] + public void DeploymentCleanupRequestThrowsForMissingPaths() + { + // Arrange + LauncherPaths paths = null!; + + // Act + Action act = () => new DeploymentCleanupRequest(paths); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void DeploymentRecoveryRequestThrowsForMissingPaths() + { + // Arrange + LauncherPaths paths = null!; + + // Act + Action act = () => new DeploymentRecoveryRequest(paths); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void DeploymentFailureNormalizesMissingPath() + { + // Arrange + string path = null!; + + // Act + var failure = new DeploymentFailure( + DeploymentFailureKind.FileSystem, + path, + "Unable to deploy file."); + + // Assert + failure.Path.Should().BeEmpty(); + } + + [Fact] + public void DeploymentFailureThrowsForMissingMessage() + { + // Arrange + string message = " "; + + // Act + Action act = () => new DeploymentFailure( + DeploymentFailureKind.FileSystem, + @"C:\Games\ZeroHour\Game.dat", + message); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void DeploymentManifestThrowsForMissingDeploymentId() + { + // Arrange + IReadOnlyList files = new[] { CreateFileEntry() }; + IReadOnlyList createdDirectories = new[] { "Data" }; + + // Act + Action act = () => new DeploymentManifest( + 1, + " ", + DateTimeOffset.UtcNow, + files, + createdDirectories); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void DeploymentManifestThrowsForMissingFiles() + { + // Arrange + IReadOnlyList createdDirectories = new[] { "Data" }; + + // Act + Action act = () => new DeploymentManifest( + 1, + "deployment-id", + DateTimeOffset.UtcNow, + null!, + createdDirectories); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void DeploymentManifestThrowsForMissingCreatedDirectories() + { + // Arrange + IReadOnlyList files = new[] { CreateFileEntry() }; + + // Act + Action act = () => new DeploymentManifest( + 1, + "deployment-id", + DateTimeOffset.UtcNow, + files, + null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void DeploymentResultSuccessCreatesSuccessfulResult() + { + // Arrange + DeploymentManifest manifest = CreateManifest(); + + // Act + var result = DeploymentResult.Success(manifest); + + // Assert + result.Succeeded.Should().BeTrue(); + result.Failures.Should().BeEmpty(); + result.Manifest.Should().Be(manifest); + } + + [Fact] + public void DeploymentResultFailureCreatesFailedResult() + { + // Arrange + var failure = new DeploymentFailure( + DeploymentFailureKind.Manifest, + @"C:\Games\ZeroHour\GenLauncherGO\deployment.json", + "Unable to load manifest."); + + // Act + var result = DeploymentResult.Failure(failure); + + // Assert + result.Succeeded.Should().BeFalse(); + result.Failures.Should().ContainSingle().Which.Should().Be(failure); + result.Manifest.Should().BeNull(); + } + + [Fact] + public void GameLaunchResultFactoriesCreateExpectedResults() + { + // Arrange + var runningDuration = TimeSpan.FromSeconds(3); + + // Act + var success = GameLaunchResult.Success( + "generals.exe", + "-quickstart", + runningDuration); + var failure = GameLaunchResult.Failure( + "generals.exe", + "-quickstart", + runningDuration, + "Process exited too quickly."); + + // Assert + success.Succeeded.Should().BeTrue(); + success.ExecutableName.Should().Be("generals.exe"); + success.Arguments.Should().Be("-quickstart"); + success.RunningDuration.Should().Be(runningDuration); + success.FailureMessage.Should().BeNull(); + failure.Succeeded.Should().BeFalse(); + failure.FailureMessage.Should().Be("Process exited too quickly."); + } + + [Fact] + public void GameLaunchResultThrowsForMissingExecutableAndFailureMessage() + { + // Act + Action missingExecutable = () => GameLaunchResult.Success( + " ", + string.Empty, + TimeSpan.Zero); + Action missingFailureMessage = () => GameLaunchResult.Failure( + "generals.exe", + string.Empty, + TimeSpan.Zero, + " "); + + // Assert + missingExecutable.Should().Throw(); + missingFailureMessage.Should().Throw(); + } + + [Fact] + public void LaunchPreparationRequestUsesDefaultsAndFiltersNullVersions() + { + // Arrange + LauncherPaths paths = CreatePaths(); + ModificationVersion version = new() { Name = "Shockwave", Version = "1.0" }; + IReadOnlyList versions = new[] { version, null! }; + + // Act + LaunchPreparationRequest request = new(paths, versions!); + + // Assert + request.Paths.Should().BeSameAs(paths); + request.Versions.Should().ContainSingle().Which.Should().BeSameAs(version); + request.AddonsFolderName.Should().Be(LaunchPreparationRequest.DefaultAddonsFolderName); + request.PatchesFolderName.Should().Be(LaunchPreparationRequest.DefaultPatchesFolderName); + request.DisableBaseGameScriptFiles.Should().BeFalse(); + } + + [Fact] + public void LaunchPreparationRequestStoresBaseGameScriptDisableFlag() + { + // Arrange + LauncherPaths paths = CreatePaths(); + + // Act + LaunchPreparationRequest request = new( + paths, + Array.Empty(), + "Addons", + "Patches", + disableBaseGameScriptFiles: true); + + // Assert + request.DisableBaseGameScriptFiles.Should().BeTrue(); + } + + [Theory] + [InlineData("addons")] + [InlineData("patches")] + public void LaunchPreparationRequestThrowsForMissingFolderNames(string missingFolder) + { + // Arrange + string addonsFolder = missingFolder == "addons" ? " " : "Addons"; + string patchesFolder = missingFolder == "patches" ? " " : "Patches"; + + // Act + Action act = () => new LaunchPreparationRequest( + CreatePaths(), + Array.Empty(), + addonsFolder, + patchesFolder); + + // Assert + act.Should().Throw(); + } + + private static DeploymentPackage CreatePackage() + { + return new DeploymentPackage( + "package-id", + "Package", + DeploymentPackageKind.Mod, + @"C:\Packages\Package", + 1); + } + + private static DeploymentFileEntry CreateFileEntry() + { + return new DeploymentFileEntry( + @"C:\Packages\Package\Data\file.ini", + @"Data\file.ini", + DeploymentMethod.Copy, + null, + "package-id"); + } + + private static DeploymentManifest CreateManifest() + { + return new DeploymentManifest( + 1, + "deployment-id", + DateTimeOffset.UtcNow, + new[] { CreateFileEntry() }, + new[] { "Data" }); + } + + private static LauncherPaths CreatePaths() + { + return new LauncherPaths( + @"C:\Games\ZeroHour", + @"C:\Games\ZeroHour\GenLauncherGO", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache\Images", + @"C:\Games\ZeroHour\GenLauncherGO\Mods", + @"C:\Games\ZeroHour\GenLauncherGO\Logs", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Temp", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Deployment"); + } +} diff --git a/GenLauncherGO.Tests/Core/Launching/Models/GameExecutableDiscoveryModelTests.cs b/GenLauncherGO.Tests/Core/Launching/Models/GameExecutableDiscoveryModelTests.cs new file mode 100644 index 00000000..dde9ce98 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Launching/Models/GameExecutableDiscoveryModelTests.cs @@ -0,0 +1,49 @@ +using System; +using GenLauncherGO.Core.Launching.Models; + +namespace GenLauncherGO.Tests.Core.Launching.Models; + +public sealed class GameExecutableDiscoveryModelTests +{ + [Fact] + public void GameClientExecutableStoresConstructorValues() + { + // Arrange / Act + var executable = new GameClientExecutable("generalszh.exe", GameClientExecutableKind.Community); + + // Assert + executable.ExecutableName.Should().Be("generalszh.exe"); + executable.Kind.Should().Be(GameClientExecutableKind.Community); + } + + [Fact] + public void GameClientExecutableThrowsForMissingExecutableName() + { + // Arrange / Act + Action act = () => new GameClientExecutable(" ", GameClientExecutableKind.Community); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void WorldBuilderExecutableStoresConstructorValues() + { + // Arrange / Act + var executable = new WorldBuilderExecutable("worldbuilderzh.exe", WorldBuilderExecutableKind.Community); + + // Assert + executable.ExecutableName.Should().Be("worldbuilderzh.exe"); + executable.Kind.Should().Be(WorldBuilderExecutableKind.Community); + } + + [Fact] + public void WorldBuilderExecutableThrowsForMissingExecutableName() + { + // Arrange / Act + Action act = () => new WorldBuilderExecutable(" ", WorldBuilderExecutableKind.Community); + + // Assert + act.Should().Throw(); + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Models/GameModificationTests.cs b/GenLauncherGO.Tests/Core/Mods/Models/GameModificationTests.cs new file mode 100644 index 00000000..783fce5d --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Models/GameModificationTests.cs @@ -0,0 +1,99 @@ +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Core.Mods.Models; + +public sealed class GameModificationTests +{ + [Fact] + public void ConstructorCopiesVersionIdentityAndAddsVersion() + { + // Arrange + ModificationVersion version = CreateVersion("Featured", ModificationType.Advertising); + version.DependenceName = "Base"; + + // Act + GameModification modification = new(version); + + // Assert + modification.Name.Should().Be("Featured"); + modification.DependenceName.Should().Be("Base"); + modification.ModificationVersions.Should().ContainSingle().Which.Should().BeSameAs(version); + } + + [Fact] + public void UpdateModificationDataMergesAdvertisingMetadataIntoExistingVersion() + { + // Arrange + GameModification modification = new(CreateVersion("Featured", ModificationType.Advertising)); + ModificationVersion updatedVersion = CreateVersion("featured", ModificationType.Advertising); + updatedVersion.ModDBLink = "https://example.test/moddb"; + updatedVersion.NetworkInfo = "Network"; + updatedVersion.DiscordLink = "https://example.test/discord"; + updatedVersion.SimpleDownloadLink = "https://example.test/download.zip"; + updatedVersion.SupportLink = "https://example.test/support"; + + // Act + modification.UpdateModificationData(updatedVersion); + + // Assert + modification.ModificationVersions.Should().ContainSingle(); + modification.ModDBLink.Should().Be(updatedVersion.ModDBLink); + modification.NetworkInfo.Should().Be(updatedVersion.NetworkInfo); + modification.DiscordLink.Should().Be(updatedVersion.DiscordLink); + modification.SimpleDownloadLink.Should().Be(updatedVersion.SimpleDownloadLink); + modification.SupportLink.Should().Be(updatedVersion.SupportLink); + } + + [Fact] + public void EqualsReturnsFalseForNullAndDifferentRuntimeType() + { + // Arrange + GameModification modification = new(CreateVersion("ShockWave", ModificationType.Mod)); + object version = CreateVersion("ShockWave", ModificationType.Mod); + + // Act + bool equalsNull = modification.Equals(null); + bool equalsVersion = object.Equals(modification, version); + + // Assert + equalsNull.Should().BeFalse(); + equalsVersion.Should().BeFalse(); + } + + [Fact] + public void EqualsComparesContentIdentitiesIgnoringCase() + { + // Arrange + ModificationVersion version = CreateVersion("ShockWave", ModificationType.Addon); + version.DependenceName = "Parent"; + ModificationVersion matchingVersion = CreateVersion("shockwave", ModificationType.Addon); + matchingVersion.DependenceName = "parent"; + ModificationVersion differentParentVersion = CreateVersion("ShockWave", ModificationType.Addon); + differentParentVersion.DependenceName = "Other Parent"; + ModificationVersion differentTypeVersion = CreateVersion("ShockWave", ModificationType.Patch); + differentTypeVersion.DependenceName = "Parent"; + GameModification modification = new(version); + GameModification matchingModification = new(matchingVersion); + GameModification differentParent = new(differentParentVersion); + GameModification differentType = new(differentTypeVersion); + + // Act + bool matchesIdentity = modification.Equals(matchingModification); + + // Assert + matchesIdentity.Should().BeTrue(); + modification.Equals(differentParent).Should().BeFalse(); + modification.Equals(differentType).Should().BeFalse(); + modification.GetHashCode().Should().Be(matchingModification.GetHashCode()); + } + + private static ModificationVersion CreateVersion(string name, ModificationType modificationType) + { + return new ModificationVersion + { + Name = name, + Version = "1.0", + ModificationType = modificationType + }; + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Models/LauncherDataTests.cs b/GenLauncherGO.Tests/Core/Mods/Models/LauncherDataTests.cs new file mode 100644 index 00000000..5e0cf0e4 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Models/LauncherDataTests.cs @@ -0,0 +1,196 @@ +using System.Linq; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Core.Mods.Models; + +public sealed class LauncherDataTests +{ + [Fact] + public void AddOrUpdateAddsContentToMatchingCollections() + { + // Arrange + LauncherData launcherData = new(); + + // Act + launcherData.AddOrUpdate(CreateVersion("Shockwave", ModificationType.Mod)); + launcherData.AddOrUpdate(CreateVersion("Advertisement", ModificationType.Advertising)); + launcherData.AddOrUpdate(CreateVersion("Patch", ModificationType.Patch)); + launcherData.AddOrUpdate(CreateVersion("Addon", ModificationType.Addon, dependenceName: "Shockwave")); + launcherData.AddOrUpdate(CreateVersion("Orphan Addon", ModificationType.Addon)); + + // Assert + launcherData.Modifications.Select(modification => modification.Name) + .Should() + .BeEquivalentTo("Shockwave", "Advertisement"); + launcherData.Patches.Should().ContainSingle().Which.Name.Should().Be("Patch"); + launcherData.Addons.Should().ContainSingle().Which.Name.Should().Be("Addon"); + } + + [Fact] + public void AddOrUpdateMergesMatchingVersionsIntoExistingContentCard() + { + // Arrange + LauncherData launcherData = new(); + ModificationVersion installedVersion = CreateVersion("Shockwave", ModificationType.Mod, "1.0"); + installedVersion.Installed = true; + ModificationVersion selectedVersion = CreateVersion("shockwave", ModificationType.Mod, "1.0"); + selectedVersion.IsSelected = true; + + // Act + launcherData.AddOrUpdate(installedVersion); + launcherData.AddOrUpdate(selectedVersion); + + // Assert + GameModification modification = launcherData.Modifications.Should().ContainSingle().Which; + modification.ModificationVersions.Should().ContainSingle(); + modification.Installed.Should().BeTrue(); + modification.IsSelected.Should().BeTrue(); + } + + [Fact] + public void DeleteRemovesMatchingVersionAndDeletesEmptyContentCard() + { + // Arrange + LauncherData launcherData = new(); + ModificationVersion versionOne = CreateVersion("Shockwave", ModificationType.Mod, "1.0"); + ModificationVersion versionTwo = CreateVersion("Shockwave", ModificationType.Mod, "2.0"); + launcherData.AddOrUpdate(versionOne); + launcherData.AddOrUpdate(versionTwo); + + // Act + launcherData.Delete(versionOne); + launcherData.Delete(versionTwo); + + // Assert + launcherData.Modifications.Should().BeEmpty(); + } + + [Fact] + public void AddOrUpdateKeepsChildCardsWithSameNameUnderDifferentParentsSeparate() + { + // Arrange + LauncherData launcherData = new(); + ModificationVersion firstAddon = CreateVersion("Shared Addon", ModificationType.Addon, dependenceName: "First"); + ModificationVersion secondAddon = CreateVersion("Shared Addon", ModificationType.Addon, dependenceName: "Second"); + + // Act + launcherData.AddOrUpdate(firstAddon); + launcherData.AddOrUpdate(secondAddon); + + // Assert + launcherData.Addons.Should().HaveCount(2); + launcherData.Addons.Should().ContainSingle(addon => addon.DependenceName == "First"); + launcherData.Addons.Should().ContainSingle(addon => addon.DependenceName == "Second"); + } + + [Fact] + public void DeleteModAlsoDeletesDependentAddonAndPatchCards() + { + // Arrange + LauncherData launcherData = new(); + ModificationVersion mod = CreateVersion("Parent", ModificationType.Mod); + ModificationVersion addon = CreateVersion("Addon", ModificationType.Addon, dependenceName: "Parent"); + ModificationVersion patch = CreateVersion("Patch", ModificationType.Patch, dependenceName: "Parent"); + ModificationVersion patchAddon = CreateVersion("Patch Addon", ModificationType.Addon, dependenceName: "Patch"); + ModificationVersion unrelatedAddon = CreateVersion("Addon", ModificationType.Addon, dependenceName: "Other"); + launcherData.AddOrUpdate(mod); + launcherData.AddOrUpdate(addon); + launcherData.AddOrUpdate(patch); + launcherData.AddOrUpdate(patchAddon); + launcherData.AddOrUpdate(unrelatedAddon); + + // Act + launcherData.Delete(mod); + + // Assert + launcherData.Modifications.Should().BeEmpty(); + launcherData.Addons.Should().ContainSingle().Which.DependenceName.Should().Be("Other"); + launcherData.Patches.Should().BeEmpty(); + } + + [Fact] + public void DeleteAddonRemovesOnlyMatchingAddonCard() + { + // Arrange + LauncherData launcherData = new(); + ModificationVersion addon = CreateVersion("Addon", ModificationType.Addon, dependenceName: "Shockwave"); + ModificationVersion patch = CreateVersion("Addon", ModificationType.Patch); + launcherData.AddOrUpdate(addon); + launcherData.AddOrUpdate(patch); + + // Act + launcherData.Delete(addon); + + // Assert + launcherData.Addons.Should().BeEmpty(); + launcherData.Patches.Should().ContainSingle(); + } + + [Fact] + public void DeletePatchRemovesOnlyMatchingPatchCard() + { + // Arrange + LauncherData launcherData = new(); + ModificationVersion addon = CreateVersion("Patch", ModificationType.Addon, dependenceName: "Shockwave"); + ModificationVersion patch = CreateVersion("Patch", ModificationType.Patch); + launcherData.AddOrUpdate(addon); + launcherData.AddOrUpdate(patch); + + // Act + launcherData.Delete(patch); + + // Assert + launcherData.Patches.Should().BeEmpty(); + launcherData.Addons.Should().ContainSingle(); + } + + [Fact] + public void DeletePatchAlsoDeletesDependentAddonCards() + { + // Arrange + LauncherData launcherData = new(); + ModificationVersion patch = CreateVersion("Patch", ModificationType.Patch, dependenceName: "Shockwave"); + ModificationVersion dependentAddon = CreateVersion("Addon", ModificationType.Addon, dependenceName: "Patch"); + ModificationVersion unrelatedAddon = CreateVersion("Addon", ModificationType.Addon, dependenceName: "Other"); + launcherData.AddOrUpdate(patch); + launcherData.AddOrUpdate(dependentAddon); + launcherData.AddOrUpdate(unrelatedAddon); + + // Act + launcherData.Delete(patch); + + // Assert + launcherData.Patches.Should().BeEmpty(); + launcherData.Addons.Should().ContainSingle().Which.DependenceName.Should().Be("Other"); + } + + [Fact] + public void DeleteAdvertisingRemovesMatchingModificationCard() + { + // Arrange + LauncherData launcherData = new(); + ModificationVersion advertising = CreateVersion("Featured", ModificationType.Advertising); + launcherData.AddOrUpdate(advertising); + + // Act + launcherData.Delete(advertising); + + // Assert + launcherData.Modifications.Should().BeEmpty(); + } + + private static ModificationVersion CreateVersion( + string name, + ModificationType modificationType, + string version = "1.0", + string dependenceName = "") + { + return new ModificationVersion + { + Name = name, + Version = version, + ModificationType = modificationType, + DependenceName = dependenceName + }; + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Models/ModificationImageReplacementRequestTests.cs b/GenLauncherGO.Tests/Core/Mods/Models/ModificationImageReplacementRequestTests.cs new file mode 100644 index 00000000..fdda3093 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Models/ModificationImageReplacementRequestTests.cs @@ -0,0 +1,32 @@ +using System; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Core.Mods.Models; + +public sealed class ModificationImageReplacementRequestTests +{ + [Fact] + public void ConstructorStoresRequestValues() + { + // Arrange and Act + var request = new ModificationImageReplacementRequest( + "ShockWave", + "1.2", + @"C:\Images\banner.png"); + + // Assert + request.ModificationName.Should().Be("ShockWave"); + request.ImageBaseName.Should().Be("1.2"); + request.SourceImagePath.Should().Be(@"C:\Images\banner.png"); + } + + [Fact] + public void ConstructorThrowsForMissingModificationName() + { + // Arrange + Action act = () => new ModificationImageReplacementRequest(" ", "1.2", @"C:\Images\banner.png"); + + // Act and Assert + act.Should().Throw(); + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Models/ModificationReposVersionTests.cs b/GenLauncherGO.Tests/Core/Mods/Models/ModificationReposVersionTests.cs new file mode 100644 index 00000000..c4ce2713 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Models/ModificationReposVersionTests.cs @@ -0,0 +1,79 @@ +using System; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Core.Mods.Models; + +public sealed class ModificationReposVersionTests +{ + [Fact] + public void ConstructorStoresPackageAndImageValues() + { + // Arrange and Act + ModificationReposVersion version = new( + "ShockWave", + "1.2", + "https://example.test/package.zip", + "https://example.test/image.png"); + + // Assert + version.Name.Should().Be("ShockWave"); + version.Version.Should().Be("1.2"); + version.SimpleDownloadLink.Should().Be("https://example.test/package.zip"); + version.UIImageSourceLink.Should().Be("https://example.test/image.png"); + } + + [Fact] + public void DisplayName_JoinsNonEmptyNameAndVersion() + { + // Arrange + ModificationReposVersion version = new() + { + Name = "ShockWave", + Version = "1.2", + }; + + // Act and Assert + version.DisplayName.Should().Be("ShockWave 1.2"); + } + + [Theory] + [InlineData("https://s3.example.test", "mods", "ShockWave/1.2", "", ContentSourceKind.UnknownLegacy, ContentSourceKind.ManagedS3)] + [InlineData("", "", "", "https://example.test/package.zip", ContentSourceKind.UnknownLegacy, ContentSourceKind.ManagedSingleFile)] + [InlineData("", "", "", "", ContentSourceKind.Manual, ContentSourceKind.Manual)] + public void ResolveContentSourceKind_UsesPackageMetadataPrecedence( + string s3HostLink, + string s3BucketName, + string s3FolderName, + string simpleDownloadLink, + ContentSourceKind fallbackSourceKind, + ContentSourceKind expectedSourceKind) + { + // Act + ContentSourceKind sourceKind = ModificationReposVersion.ResolveContentSourceKind( + s3HostLink, + s3BucketName, + s3FolderName, + simpleDownloadLink, + fallbackSourceKind); + + // Assert + sourceKind.Should().Be(expectedSourceKind); + } + + [Fact] + public void EqualityUsesNameCaseInsensitively() + { + // Arrange + ModificationReposVersion version = new("ShockWave"); + ModificationReposVersion sameVersion = new("shockwave"); + ModificationReposVersion otherVersion = new("Contra"); + + // Act and Assert + version.Equals(sameVersion).Should().BeTrue(); + version.Equals(otherVersion).Should().BeFalse(); + version.Equals(new object()).Should().BeFalse(); + version.GetHashCode().Should().Be(sameVersion.GetHashCode()); + version.ToString().Should().Be("ShockWave"); + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Models/ModificationVersionTests.cs b/GenLauncherGO.Tests/Core/Mods/Models/ModificationVersionTests.cs new file mode 100644 index 00000000..f12366c7 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Models/ModificationVersionTests.cs @@ -0,0 +1,288 @@ +using System; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Core.Mods.Models; + +public sealed class ModificationVersionTests +{ + [Fact] + public void ConstructorCopiesRepositoryMetadataAndEffectiveSourceKind() + { + // Arrange + ModificationReposVersion repositoryVersion = CreateRepositoryVersion(); + + // Act + ModificationVersion version = new(repositoryVersion); + + // Assert + version.Name.Should().Be(repositoryVersion.Name); + version.Version.Should().Be(repositoryVersion.Version); + version.ModificationType.Should().Be(repositoryVersion.ModificationType); + version.DependenceName.Should().Be(repositoryVersion.DependenceName); + version.ModDBLink.Should().Be(repositoryVersion.ModDBLink); + version.DiscordLink.Should().Be(repositoryVersion.DiscordLink); + version.SimpleDownloadLink.Should().Be(repositoryVersion.SimpleDownloadLink); + version.UIImageSourceLink.Should().Be(repositoryVersion.UIImageSourceLink); + version.NewsLink.Should().Be(repositoryVersion.NewsLink); + version.NetworkInfo.Should().Be(repositoryVersion.NetworkInfo); + version.S3HostLink.Should().Be(repositoryVersion.S3HostLink); + version.S3BucketName.Should().Be(repositoryVersion.S3BucketName); + version.S3FolderName.Should().Be(repositoryVersion.S3FolderName); + version.S3HostPublicKey.Should().Be(repositoryVersion.S3HostPublicKey); + version.S3HostSecretKey.Should().Be(repositoryVersion.S3HostSecretKey); + version.Deprecated.Should().Be(repositoryVersion.Deprecated); + version.ColorsInformation.Should().BeSameAs(repositoryVersion.ColorsInformation); + version.SupportLink.Should().Be(repositoryVersion.SupportLink); + version.ContentSourceKind.Should().Be(ContentSourceKind.ManagedS3); + } + + [Fact] + public void CompareTo_ComparesNumericVersionValues() + { + // Arrange + ModificationVersion version = new() + { + Version = "1.2", + }; + ModificationVersion sameNumericVersion = new() + { + Version = "1.20", + }; + ModificationVersion newerVersion = new() + { + Version = "1.3", + }; + + // Act and Assert + version.CompareTo(sameNumericVersion).Should().Be(0); + version.CompareTo(newerVersion).Should().BeNegative(); + } + + [Fact] + public void CompareTo_TreatsMissingDigitsAsLowerThanNumericVersion() + { + // Arrange + ModificationVersion version = new() + { + Version = "beta", + }; + ModificationVersion newerVersion = new() + { + Version = "1", + }; + + // Act and Assert + version.CompareTo(newerVersion).Should().BeNegative(); + } + + [Fact] + public void CompareTo_TreatsNumericVersionAsGreaterThanMissingDigits() + { + // Arrange + ModificationVersion version = new() + { + Version = "1", + }; + ModificationVersion emptyVersion = new() + { + Version = "release", + }; + + // Act and Assert + version.CompareTo(emptyVersion).Should().BePositive(); + } + + [Fact] + public void CompareTo_TreatsTwoNonnumericVersionsAsEqual() + { + // Arrange + ModificationVersion version = new() + { + Version = "release", + }; + ModificationVersion otherVersion = new() + { + Version = "beta", + }; + + // Act and Assert + version.CompareTo(otherVersion).Should().Be(0); + } + + [Fact] + public void CompareTo_NormalizesShorterNumericVersionOnEitherSide() + { + // Arrange + ModificationVersion newerVersion = new() + { + Version = "1.3", + }; + ModificationVersion olderVersion = new() + { + Version = "1", + }; + ModificationVersion emptyVersion = new() + { + Version = "release", + }; + + // Act and Assert + newerVersion.CompareTo(olderVersion).Should().BePositive(); + olderVersion.CompareTo(emptyVersion).Should().BePositive(); + } + + [Fact] + public void CompareTo_ThrowsForDifferentObjectType() + { + // Arrange + ModificationVersion version = new(); + + // Act + Action act = () => version.CompareTo(new object()); + + // Assert + act.Should().Throw() + .WithMessage("Cannot compare 2 objects"); + } + + [Fact] + public void EqualityUsesContentIdentityCaseInsensitively() + { + // Arrange + ModificationVersion version = new() + { + Name = "ShockWave", + Version = "1.2", + ModificationType = ModificationType.Addon, + DependenceName = "Parent" + }; + ModificationVersion sameVersion = new() + { + Name = "shockwave", + Version = "1.2", + ModificationType = ModificationType.Addon, + DependenceName = "parent" + }; + ModificationVersion otherVersion = new() + { + Name = "ShockWave", + Version = "2.0", + ModificationType = ModificationType.Addon, + DependenceName = "Parent" + }; + ModificationVersion otherParent = new() + { + Name = "ShockWave", + Version = "1.2", + ModificationType = ModificationType.Addon, + DependenceName = "Other Parent" + }; + ModificationVersion otherType = new() + { + Name = "ShockWave", + Version = "1.2", + ModificationType = ModificationType.Patch, + DependenceName = "Parent" + }; + + // Act and Assert + version.Equals(sameVersion).Should().BeTrue(); + version.Equals(otherVersion).Should().BeFalse(); + version.Equals(otherParent).Should().BeFalse(); + version.Equals(otherType).Should().BeFalse(); + version.Equals(new object()).Should().BeFalse(); + version.GetHashCode().Should().Be(sameVersion.GetHashCode()); + } + + [Fact] + public void UnionModifications_MergesMissingMetadataAndState() + { + // Arrange + ColorsInfoString colors = new() + { + GenLauncherActiveColor = "#fff", + }; + ModificationVersion target = new() + { + Name = "ShockWave", + Version = "1.2", + ContentSourceKind = ContentSourceKind.UnknownLegacy, + }; + ModificationVersion source = new() + { + Installed = true, + IsSelected = true, + SimpleDownloadLink = "https://example.test/package.zip", + ModificationType = ModificationType.Addon, + UIImageSourceLink = "https://example.test/image.png", + DependenceName = "Generals", + NewsLink = "https://example.test/news", + ModDBLink = "https://example.test/moddb", + DiscordLink = "https://example.test/discord", + NetworkInfo = "network", + SupportLink = "https://example.test/support", + S3BucketName = "mods", + S3FolderName = "ShockWave/1.2", + S3HostLink = "https://s3.example.test", + S3HostPublicKey = "access", + S3HostSecretKey = "secret", + Deprecated = true, + ColorsInformation = colors, + ContentSourceKind = ContentSourceKind.ManagedS3, + }; + + // Act + target.UnionModifications(source); + + // Assert + target.Installed.Should().BeTrue(); + target.IsSelected.Should().BeTrue(); + target.SimpleDownloadLink.Should().Be(source.SimpleDownloadLink); + target.ModificationType.Should().Be(ModificationType.Addon); + target.UIImageSourceLink.Should().Be(source.UIImageSourceLink); + target.DependenceName.Should().Be(source.DependenceName); + target.NewsLink.Should().Be(source.NewsLink); + target.ModDBLink.Should().Be(source.ModDBLink); + target.DiscordLink.Should().Be(source.DiscordLink); + target.NetworkInfo.Should().Be(source.NetworkInfo); + target.SupportLink.Should().Be(source.SupportLink); + target.S3BucketName.Should().Be(source.S3BucketName); + target.S3FolderName.Should().Be(source.S3FolderName); + target.S3HostLink.Should().Be(source.S3HostLink); + target.S3HostPublicKey.Should().Be(source.S3HostPublicKey); + target.S3HostSecretKey.Should().Be(source.S3HostSecretKey); + target.Deprecated.Should().BeTrue(); + target.ColorsInformation.Should().BeSameAs(colors); + target.ContentSourceKind.Should().Be(ContentSourceKind.ManagedS3); + } + + private static ModificationReposVersion CreateRepositoryVersion() + { + return new ModificationReposVersion + { + Name = "ShockWave", + Version = "1.2", + ModificationType = ModificationType.Mod, + DependenceName = "Generals", + ModDBLink = "https://example.test/moddb", + DiscordLink = "https://example.test/discord", + SimpleDownloadLink = "https://example.test/package.zip", + UIImageSourceLink = "https://example.test/image.png", + NewsLink = "https://example.test/news", + NetworkInfo = "network", + S3HostLink = "https://s3.example.test", + S3BucketName = "mods", + S3FolderName = "ShockWave/1.2", + S3HostPublicKey = "access", + S3HostSecretKey = "secret", + Deprecated = true, + ColorsInformation = new ColorsInfoString + { + GenLauncherActiveColor = "#fff", + }, + SupportLink = "https://example.test/support", + ContentSourceKind = ContentSourceKind.UnknownLegacy, + }; + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Services/LauncherContentPathResolverTests.cs b/GenLauncherGO.Tests/Core/Mods/Services/LauncherContentPathResolverTests.cs new file mode 100644 index 00000000..83c7e225 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Services/LauncherContentPathResolverTests.cs @@ -0,0 +1,191 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Mods.Services; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Tests.Core.Mods.Services; + +public sealed class LauncherContentPathResolverTests +{ + [Fact] + public void GetVersionDirectoryPath_WhenVersionIsMod_ReturnsModVersionDirectory() + { + // Arrange + var resolver = new LauncherContentPathResolver(); + LauncherPaths paths = CreatePaths(); + LauncherContentLayout layout = CreateLayout(); + var version = new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "Rise Of Reds", + Version = "1.9" + }; + + // Act + string result = resolver.GetVersionDirectoryPath(paths, layout, version); + + // Assert + result.Should().Be(Path.Combine(paths.ModsDirectory, "Rise Of Reds", "1.9")); + } + + [Fact] + public void GetVersionDirectoryPath_WhenVersionIsAddon_ReturnsAddonVersionDirectory() + { + // Arrange + var resolver = new LauncherContentPathResolver(); + LauncherPaths paths = CreatePaths(); + LauncherContentLayout layout = CreateLayout(); + var version = new ModificationVersion + { + ModificationType = ModificationType.Addon, + DependenceName = "Rise Of Reds", + Name = "Music Pack", + Version = "2.0" + }; + + // Act + string result = resolver.GetVersionDirectoryPath(paths, layout, version); + + // Assert + result.Should().Be(Path.Combine( + paths.ModsDirectory, + "Rise Of Reds", + "Addons", + "Music Pack", + "2.0")); + } + + [Fact] + public void GetVersionDirectoryPath_WhenVersionIsPatch_ReturnsPatchVersionDirectory() + { + // Arrange + var resolver = new LauncherContentPathResolver(); + LauncherPaths paths = CreatePaths(); + LauncherContentLayout layout = CreateLayout(); + var version = new ModificationVersion + { + ModificationType = ModificationType.Patch, + DependenceName = "Rise Of Reds", + Name = "Hotfix", + Version = "2.1" + }; + + // Act + string result = resolver.GetVersionDirectoryPath(paths, layout, version); + + // Assert + result.Should().Be(Path.Combine( + paths.ModsDirectory, + "Rise Of Reds", + "Patches", + "Hotfix", + "2.1")); + } + + [Fact] + public void GetVersionDirectoryPath_WhenVersionTypeIsUnsupported_ReturnsEmptyString() + { + // Arrange + var resolver = new LauncherContentPathResolver(); + LauncherPaths paths = CreatePaths(); + LauncherContentLayout layout = CreateLayout(); + var version = new ModificationVersion + { + ModificationType = ModificationType.Advertising, + Name = "News", + Version = "1" + }; + + // Act + string result = resolver.GetVersionDirectoryPath(paths, layout, version); + + // Assert + result.Should().BeEmpty(); + } + + [Fact] + public void GetVersionDirectoryPath_WhenModNameContainsPathTraversal_Throws() + { + // Arrange + var resolver = new LauncherContentPathResolver(); + LauncherPaths paths = CreatePaths(); + LauncherContentLayout layout = CreateLayout(); + var version = new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = $"..{Path.DirectorySeparatorChar}Escape", + Version = "1.0" + }; + + // Act + Action act = () => resolver.GetVersionDirectoryPath(paths, layout, version); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void GetVersionDirectoryPath_WhenAddonDependenceContainsPathTraversal_Throws() + { + // Arrange + var resolver = new LauncherContentPathResolver(); + LauncherPaths paths = CreatePaths(); + LauncherContentLayout layout = CreateLayout(); + var version = new ModificationVersion + { + ModificationType = ModificationType.Addon, + DependenceName = $"..{Path.DirectorySeparatorChar}Escape", + Name = "Music Pack", + Version = "1.0" + }; + + // Act + Action act = () => resolver.GetVersionDirectoryPath(paths, layout, version); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void GetVersionDirectoryPath_WhenPatchVersionContainsPathTraversal_Throws() + { + // Arrange + var resolver = new LauncherContentPathResolver(); + LauncherPaths paths = CreatePaths(); + LauncherContentLayout layout = CreateLayout(); + var version = new ModificationVersion + { + ModificationType = ModificationType.Patch, + DependenceName = "Rise Of Reds", + Name = "Hotfix", + Version = $"..{Path.DirectorySeparatorChar}Escape" + }; + + // Act + Action act = () => resolver.GetVersionDirectoryPath(paths, layout, version); + + // Assert + act.Should().Throw(); + } + + private static LauncherPaths CreatePaths() + { + string root = Path.Combine(Path.GetTempPath(), "GenLauncherGO.Tests"); + return new LauncherPaths( + Path.Combine(root, "Game"), + Path.Combine(root, "Launcher"), + Path.Combine(root, "Launcher", "Runtime"), + Path.Combine(root, "Launcher", "Cache"), + Path.Combine(root, "Launcher", "Images"), + Path.Combine(root, "Launcher", "Mods"), + Path.Combine(root, "Launcher", "Logs"), + Path.Combine(root, "Launcher", "Temp"), + Path.Combine(root, "Launcher", "Deployment")); + } + + private static LauncherContentLayout CreateLayout() + { + return new LauncherContentLayout("Addons", "Patches"); + } +} diff --git a/GenLauncherGO.Tests/Core/SessionInformationTests.cs b/GenLauncherGO.Tests/Core/SessionInformationTests.cs new file mode 100644 index 00000000..fea02135 --- /dev/null +++ b/GenLauncherGO.Tests/Core/SessionInformationTests.cs @@ -0,0 +1,38 @@ +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Tests.Core; + +public sealed class SessionInformationTests +{ + [Fact] + public void SessionInformationDefaultsToUnknownGame() + { + // Arrange + var sessionInformation = new SessionInformation(); + + // Act + SupportedGame managedGame = sessionInformation.CurrentlyManagedGame; + + // Assert + managedGame.Should().Be(SupportedGame.Unknown); + } + + [Fact] + public void SessionInformationStoresStartupState() + { + // Arrange + var sessionInformation = new SessionInformation + { + Connected = true, + CurrentlyManagedGame = SupportedGame.Generals + }; + + // Act + bool connected = sessionInformation.Connected; + SupportedGame managedGame = sessionInformation.CurrentlyManagedGame; + + // Assert + connected.Should().BeTrue(); + managedGame.Should().Be(SupportedGame.Generals); + } +} diff --git a/GenLauncherGO.Tests/Core/Shell/Models/ShellOpenResultTests.cs b/GenLauncherGO.Tests/Core/Shell/Models/ShellOpenResultTests.cs new file mode 100644 index 00000000..734f2163 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Shell/Models/ShellOpenResultTests.cs @@ -0,0 +1,33 @@ +using System; +using GenLauncherGO.Core.Shell.Models; + +namespace GenLauncherGO.Tests.Core.Shell.Models; + +public sealed class ShellOpenResultTests +{ + [Fact] + public void SuccessCreatesSuccessfulResult() + { + // Arrange + const string target = "https://example.test/"; + + // Act + var result = ShellOpenResult.Success(target); + + // Assert + result.Succeeded.Should().BeTrue(); + result.FailureKind.Should().Be(ShellOpenFailureKind.None); + result.Target.Should().Be(target); + result.Message.Should().BeNull(); + } + + [Fact] + public void FailureRejectsNoneFailureKind() + { + // Arrange + Action act = () => ShellOpenResult.Failure(ShellOpenFailureKind.None, "target", "message"); + + // Act and Assert + act.Should().Throw(); + } +} diff --git a/GenLauncherGO.Tests/Core/Startup/LauncherPathsTests.cs b/GenLauncherGO.Tests/Core/Startup/LauncherPathsTests.cs new file mode 100644 index 00000000..d1d11bc0 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Startup/LauncherPathsTests.cs @@ -0,0 +1,171 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Tests.Core.Startup; + +public sealed class LauncherPathsTests +{ + [Fact] + public void GetPackageTemporaryFolderPathBuildsPathUnderTempDirectory() + { + // Arrange + LauncherPaths paths = CreatePaths(); + string installedFolderPath = Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"); + + // Act + string temporaryFolderPath = paths.GetPackageTemporaryFolderPath(installedFolderPath); + + // Assert + temporaryFolderPath.Should().Be(Path.Combine(paths.TempDirectory, "Packages", "ShockWave", "1.2")); + } + + [Fact] + public void GetPackageTemporaryFolderPathUsesFolderNameWhenInstallIsOutsideModsDirectory() + { + // Arrange + LauncherPaths paths = CreatePaths(); + string installedFolderPath = Path.Combine(paths.GameDirectory, "Data"); + + // Act + string temporaryFolderPath = paths.GetPackageTemporaryFolderPath(installedFolderPath); + + // Assert + temporaryFolderPath.Should().Be(Path.Combine(paths.TempDirectory, "Packages", "Data")); + } + + [Fact] + public void GetPackageTemporaryFolderPathPreservesModsChildNamesThatStartWithDots() + { + // Arrange + LauncherPaths paths = CreatePaths(); + string installedFolderPath = Path.Combine(paths.ModsDirectory, "..cache", "1.0"); + + // Act + string temporaryFolderPath = paths.GetPackageTemporaryFolderPath(installedFolderPath); + + // Assert + temporaryFolderPath.Should().Be(Path.Combine(paths.TempDirectory, "Packages", "..cache", "1.0")); + } + + [Fact] + public void LauncherDataFilePathBuildsPathUnderRuntimeStateDirectory() + { + // Arrange + LauncherPaths paths = CreatePaths(); + + // Act + string launcherDataFilePath = paths.LauncherDataFilePath; + + // Assert + launcherDataFilePath.Should().Be( + Path.Combine(paths.RuntimeDirectory, "State", "LauncherData.yaml")); + } + + [Fact] + public void PreferencesFilePathBuildsPathUnderLauncherRoot() + { + // Arrange + LauncherPaths paths = CreatePaths(); + + // Act + string preferencesFilePath = paths.PreferencesFilePath; + + // Assert + preferencesFilePath.Should().Be(Path.Combine(paths.LauncherDirectory, "LauncherPreferences.yaml")); + } + + [Fact] + public void GetModificationImageFilePathBuildsPathUnderModificationImageCache() + { + // Arrange + LauncherPaths paths = CreatePaths(); + + // Act + string imageFilePath = paths.GetModificationImageFilePath("ShockWave", "1.2.png"); + + // Assert + imageFilePath.Should().Be(Path.Combine(paths.ImagesDirectory, "ShockWave", "1.2.png")); + } + + [Fact] + public void GetModificationImagesDirectoryThrowsForMissingModificationName() + { + // Arrange + LauncherPaths paths = CreatePaths(); + + // Act + Action act = () => paths.GetModificationImagesDirectory(" "); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void GetModificationImagesDirectoryThrowsForPathTraversalModificationName() + { + // Arrange + LauncherPaths paths = CreatePaths(); + + // Act + Action act = () => paths.GetModificationImagesDirectory($"..{Path.DirectorySeparatorChar}Escape"); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void GetModificationImageFilePathThrowsForMissingImageFileName() + { + // Arrange + LauncherPaths paths = CreatePaths(); + + // Act + Action act = () => paths.GetModificationImageFilePath("ShockWave", " "); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void GetModificationImageFilePathThrowsForPathTraversalImageFileName() + { + // Arrange + LauncherPaths paths = CreatePaths(); + + // Act + Action act = () => paths.GetModificationImageFilePath( + "ShockWave", + $"..{Path.DirectorySeparatorChar}1.2.png"); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void GetPackageTemporaryFolderPathThrowsForMissingInstalledFolderPath() + { + // Arrange + LauncherPaths paths = CreatePaths(); + + // Act + Action act = () => paths.GetPackageTemporaryFolderPath(" "); + + // Assert + act.Should().Throw(); + } + + private static LauncherPaths CreatePaths() + { + return new LauncherPaths( + @"C:\Games\ZeroHour", + @"C:\Games\ZeroHour\GenLauncherGO", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache\Images", + @"C:\Games\ZeroHour\GenLauncherGO\Mods", + @"C:\Games\ZeroHour\GenLauncherGO\Logs", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Temp", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Deployment"); + } +} diff --git a/GenLauncherGO.Tests/GenLauncherGO.Tests.csproj b/GenLauncherGO.Tests/GenLauncherGO.Tests.csproj new file mode 100644 index 00000000..1e770945 --- /dev/null +++ b/GenLauncherGO.Tests/GenLauncherGO.Tests.csproj @@ -0,0 +1,37 @@ + + + net10.0-windows + true + disable + enable + 14.0 + false + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/GenLauncherGO.Tests/GlobalUsings.cs b/GenLauncherGO.Tests/GlobalUsings.cs new file mode 100644 index 00000000..82e9ad1e --- /dev/null +++ b/GenLauncherGO.Tests/GlobalUsings.cs @@ -0,0 +1,3 @@ +global using FluentAssertions; +global using NSubstitute; +global using Xunit; diff --git a/GenLauncherGO.Tests/Infrastructure/.gitkeep b/GenLauncherGO.Tests/Infrastructure/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/.gitkeep @@ -0,0 +1 @@ + diff --git a/GenLauncherGO.Tests/Infrastructure/ArchiveExtractorTests.cs b/GenLauncherGO.Tests/Infrastructure/ArchiveExtractorTests.cs new file mode 100644 index 00000000..eaffac92 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/ArchiveExtractorTests.cs @@ -0,0 +1,133 @@ +using System; +using System.IO; +using System.IO.Compression; +using GenLauncherGO.Core.Archives; +using GenLauncherGO.Infrastructure.Archives; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Tests.Infrastructure; + +public sealed class ArchiveExtractorTests +{ + [Fact] + public void AddGenLauncherGoArchivesRegistersArchiveExtractorContract() + { + // Arrange + var services = new ServiceCollection(); + services.AddLogging(); + + // Act + services.AddGenLauncherGoArchives(); + using ServiceProvider provider = services.BuildServiceProvider(); + + // Assert + provider.GetRequiredService().Should().BeOfType(); + } + + [Fact] + public void ExtractToDirectoryPreservesBigFilesByDefault() + { + // Arrange + string workingDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + string archivePath = Path.Combine(workingDirectory, "mod.zip"); + string destinationDirectory = Path.Combine(workingDirectory, "extract"); + + Directory.CreateDirectory(workingDirectory); + try + { + CreateZipArchive(archivePath, "Data/test.big", "test data"); + + var extractor = new ArchiveExtractor(); + + // Act + extractor.ExtractToDirectory(archivePath, destinationDirectory); + + // Assert + File.Exists(Path.Combine(destinationDirectory, "Data", "test.big")).Should().BeTrue(); + File.Exists(Path.Combine(destinationDirectory, "Data", "test.gib")).Should().BeFalse(); + } + finally + { + if (Directory.Exists(workingDirectory)) + { + Directory.Delete(workingDirectory, recursive: true); + } + } + } + + [Fact] + public void ExtractToDirectoryConvertsBigFilesWhenRequested() + { + // Arrange + string workingDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + string archivePath = Path.Combine(workingDirectory, "mod.zip"); + string destinationDirectory = Path.Combine(workingDirectory, "extract"); + + Directory.CreateDirectory(workingDirectory); + try + { + CreateZipArchive(archivePath, "Data/test.big", "test data"); + + var extractor = new ArchiveExtractor(); + + // Act + extractor.ExtractToDirectory( + archivePath, + destinationDirectory, + new ArchiveExtractionOptions { ConvertBigFilesToGib = true }); + + // Assert + File.Exists(Path.Combine(destinationDirectory, "Data", "test.gib")).Should().BeTrue(); + File.Exists(Path.Combine(destinationDirectory, "Data", "test.big")).Should().BeFalse(); + } + finally + { + if (Directory.Exists(workingDirectory)) + { + Directory.Delete(workingDirectory, recursive: true); + } + } + } + + [Fact] + public void ExtractToDirectoryRejectsEntriesOutsideDestinationDirectory() + { + // Arrange + string workingDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + string archivePath = Path.Combine(workingDirectory, "mod.zip"); + string destinationDirectory = Path.Combine(workingDirectory, "extract"); + string escapedFilePath = Path.Combine(workingDirectory, "escape.txt"); + + Directory.CreateDirectory(workingDirectory); + try + { + CreateZipArchive(archivePath, "../escape.txt", "escaped"); + + var extractor = new ArchiveExtractor(); + + // Act + Action act = () => extractor.ExtractToDirectory(archivePath, destinationDirectory); + + // Assert + act.Should().Throw() + .WithMessage("*outside the destination folder*"); + File.Exists(escapedFilePath).Should().BeFalse(); + } + finally + { + if (Directory.Exists(workingDirectory)) + { + Directory.Delete(workingDirectory, recursive: true); + } + } + } + + private static void CreateZipArchive(string archivePath, string entryName, string contents) + { + using ZipArchive archive = ZipFile.Open(archivePath, ZipArchiveMode.Create); + ZipArchiveEntry entry = archive.CreateEntry(entryName); + using Stream entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream); + writer.Write(contents); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Common/FileSystemPathSafetyTests.cs b/GenLauncherGO.Tests/Infrastructure/Common/FileSystemPathSafetyTests.cs new file mode 100644 index 00000000..c1411250 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Common/FileSystemPathSafetyTests.cs @@ -0,0 +1,166 @@ +using System; +using System.IO; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Common; + +public sealed class FileSystemPathSafetyTests +{ + [Fact] + public void IsPathInDirectoryAcceptsDirectoryItselfAndChildPaths() + { + // Arrange + using TestDirectory directory = new(); + string childPath = Path.Combine(directory.Path, "Child", "file.txt"); + + // Act and Assert + FileSystemPathSafety.IsPathInDirectory(directory.Path, directory.Path).Should().BeTrue(); + FileSystemPathSafety.IsPathInDirectory(childPath, directory.Path).Should().BeTrue(); + } + + [Fact] + public void IsPathInDirectoryRejectsSiblingWithMatchingPrefix() + { + // Arrange + using TestDirectory directory = new(); + string siblingPath = directory.Path + "-sibling"; + + // Act + bool result = FileSystemPathSafety.IsPathInDirectory(siblingPath, directory.Path); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void ResolveOwnedSubpathReturnsNormalizedChildPath() + { + // Arrange + using TestDirectory directory = new(); + string candidatePath = Path.Combine(directory.Path, "Child", "..", "Child", "file.txt"); + + // Act + string result = FileSystemPathSafety.ResolveOwnedSubpath( + directory.Path, + candidatePath, + "outside", + "linked"); + + // Assert + result.Should().Be(Path.GetFullPath(Path.Combine(directory.Path, "Child", "file.txt"))); + } + + [Fact] + public void ResolveOwnedSubpathRejectsPathOutsideOwnedRoot() + { + // Arrange + using TestDirectory directory = new(); + string outsidePath = Path.Combine(directory.Path, "..", "outside.txt"); + + // Act + Action act = () => FileSystemPathSafety.ResolveOwnedSubpath( + directory.Path, + outsidePath, + "outside root", + "linked path"); + + // Assert + act.Should().Throw() + .WithMessage("outside root"); + } + + [Fact] + public void ExistingPathChainContainsReparsePointReturnsFalseForRootAndMissingChild() + { + // Arrange + using TestDirectory directory = new(); + string missingChild = Path.Combine(directory.Path, "Missing", "file.txt"); + + // Act and Assert + FileSystemPathSafety.ExistingPathChainContainsReparsePoint( + Path.GetPathRoot(directory.Path)!, + "unrooted").Should().BeFalse(); + FileSystemPathSafety.ExistingPathChainContainsReparsePoint( + missingChild, + "unrooted").Should().BeFalse(); + } + + [Fact] + public void EnsureExistingPathChainHasNoReparsePointsAllowsNormalFiles() + { + // Arrange + using TestDirectory directory = new(); + string filePath = Path.Combine(directory.Path, "file.txt"); + File.WriteAllText(filePath, "content"); + + // Act + Action act = () => FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + filePath, + "unrooted", + "linked"); + + // Assert + act.Should().NotThrow(); + FileSystemPathSafety.IsReparsePoint(filePath).Should().BeFalse(); + } + + [Fact] + public void EnsureDirectoryTreeHasNoReparsePointsRejectsChildReparsePoint() + { + // Arrange + using TestDirectory directory = new(); + string rootPath = Path.Combine(directory.Path, "Root"); + string linkedTarget = Path.Combine(directory.Path, "Target"); + string linkPath = Path.Combine(rootPath, "Linked"); + Directory.CreateDirectory(rootPath); + Directory.CreateDirectory(linkedTarget); + if (!TryCreateDirectoryLink(linkPath, linkedTarget)) + { + return; + } + + // Act + Action act = () => FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints(rootPath, "linked"); + + // Assert + act.Should().Throw() + .WithMessage("linked"); + } + + [Fact] + public void NormalizeRelativePathUsesSlashSeparatorsWithoutOuterSlashes() + { + // Act + string result = FileSystemPathSafety.NormalizeRelativePath(@"\Data\INI\GameData.ini/"); + + // Assert + result.Should().Be("Data/INI/GameData.ini"); + } + + [Fact] + public void CreateRecursiveNoLinksOptionsSkipsReparsePoints() + { + // Act + EnumerationOptions result = FileSystemPathSafety.CreateRecursiveNoLinksOptions(); + + // Assert + result.AttributesToSkip.Should().Be(FileAttributes.ReparsePoint); + result.IgnoreInaccessible.Should().BeFalse(); + result.RecurseSubdirectories.Should().BeTrue(); + result.ReturnSpecialDirectories.Should().BeFalse(); + } + + private static bool TryCreateDirectoryLink(string linkPath, string targetPath) + { + try + { + Directory.CreateSymbolicLink(linkPath, targetPath); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return false; + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Integrity/Composition/IntegrityServiceCollectionExtensionsTests.cs b/GenLauncherGO.Tests/Infrastructure/Integrity/Composition/IntegrityServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..2695c90b --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Integrity/Composition/IntegrityServiceCollectionExtensionsTests.cs @@ -0,0 +1,52 @@ +using System; +using GenLauncherGO.Core.Integrity.Contracts; +using GenLauncherGO.Infrastructure.Integrity.Composition; +using GenLauncherGO.Infrastructure.Integrity.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Tests.Infrastructure.Integrity.Composition; + +public sealed class IntegrityServiceCollectionExtensionsTests +{ + [Fact] + public void AddGenLauncherGoIntegrityReturnsSameServiceCollection() + { + // Arrange + ServiceCollection services = new(); + + // Act + IServiceCollection returnedServices = services.AddGenLauncherGoIntegrity("snapshots"); + + // Assert + returnedServices.Should().BeSameAs(services); + } + + [Fact] + public void AddGenLauncherGoIntegrityThrowsForNullServices() + { + // Arrange + IServiceCollection? services = null; + + // Act + Action act = () => services!.AddGenLauncherGoIntegrity("snapshots"); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void AddGenLauncherGoIntegrityRegistersFilesystemService() + { + // Arrange + ServiceCollection services = new(); + services.AddLogging(); + + // Act + services.AddGenLauncherGoIntegrity("snapshots"); + using ServiceProvider provider = services.BuildServiceProvider(); + + // Assert + provider.GetRequiredService() + .Should().BeOfType(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Integrity/Services/FileSystemContentIntegrityServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Integrity/Services/FileSystemContentIntegrityServiceTests.cs new file mode 100644 index 00000000..afd8b997 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Integrity/Services/FileSystemContentIntegrityServiceTests.cs @@ -0,0 +1,825 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Infrastructure.Integrity.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Integrity.Services; + +public sealed class FileSystemContentIntegrityServiceTests +{ + [Fact] + public async Task VerifyAsyncReportsVerificationErrorWhenSnapshotCannotBeReadAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string snapshotDirectory = Path.Combine(directory.Path, "snapshots"); + Directory.CreateDirectory(snapshotDirectory); + await File.WriteAllTextAsync(GetSnapshotPath(snapshotDirectory, "target"), "{"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + + // Act + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.VerificationError && + issue.Action == IntegrityIssueAction.Block && + issue.RelativePath == "." && + !String.IsNullOrWhiteSpace(issue.Message)); + } + + [Fact] + public async Task VerifyAsyncDetectsSameSizeSha256ModificationAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string filePath = Path.Combine(content, "file.bin"); + await File.WriteAllTextAsync(filePath, "aaaa"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + await service.CaptureSnapshotAsync(target, CancellationToken.None); + await File.WriteAllTextAsync(filePath, "bbbb"); + + // Act + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.ModifiedFile && + issue.Action == IntegrityIssueAction.Repair && + issue.RelativePath == "file.bin" && + issue.ExpectedSizeBytes == 4); + } + + [Fact] + public async Task VerifyAsyncCollectsMissingUnexpectedAndEmptyDirectoryIssuesAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string expectedPath = Path.Combine(content, "expected.txt"); + await File.WriteAllTextAsync(expectedPath, "expected"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + await service.CaptureSnapshotAsync(target, CancellationToken.None); + File.Delete(expectedPath); + await File.WriteAllTextAsync(Path.Combine(content, "unexpected.txt"), "unexpected"); + Directory.CreateDirectory(Path.Combine(content, "nested", "empty")); + + // Act + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.MissingFile && + issue.ExpectedSizeBytes == 8); + report.Issues.Select(issue => issue.Kind).Should().Contain(IntegrityIssueKind.UnexpectedFile); + report.Issues.Select(issue => issue.Kind).Should().Contain(IntegrityIssueKind.EmptyDirectory); + } + + [Fact] + public async Task VerifyAsyncAlwaysReportsManagedEmptyDirectoriesAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(Path.Combine(content, "nested", "empty")); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + await service.CaptureSnapshotAsync(target, CancellationToken.None); + + // Act + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.EmptyDirectory && + issue.Action == IntegrityIssueAction.Delete && + issue.RelativePath == "nested/empty"); + } + + [Fact] + public async Task VerifyAsyncClassifiesManagedSingleFileDifferencesAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string expectedPath = Path.Combine(content, "expected.txt"); + await File.WriteAllTextAsync(expectedPath, "expected"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedSingleFile); + await service.CaptureSnapshotAsync(target, CancellationToken.None); + File.Delete(expectedPath); + await File.WriteAllTextAsync(Path.Combine(content, "unexpected.txt"), "unexpected"); + Directory.CreateDirectory(Path.Combine(content, "empty")); + + // Act + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.MissingFile && + issue.Action == IntegrityIssueAction.Redownload && + issue.RelativePath == "expected.txt"); + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.UnexpectedFile && + issue.Action == IntegrityIssueAction.Delete && + issue.RelativePath == "unexpected.txt"); + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.EmptyDirectory && + issue.Action == IntegrityIssueAction.Delete && + issue.RelativePath == "empty"); + } + + [Fact] + public async Task VerifyAsyncClassifiesUnknownLegacyDifferencesForManualTrustAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string filePath = Path.Combine(content, "file.txt"); + await File.WriteAllTextAsync(filePath, "before"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.UnknownLegacy); + await service.CaptureSnapshotAsync(target, CancellationToken.None); + await File.WriteAllTextAsync(filePath, "after"); + await File.WriteAllTextAsync(Path.Combine(content, "added.txt"), "added"); + + // Act + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + report.Issues.Should().NotBeEmpty(); + report.Issues.Should().OnlyContain(issue => issue.Action == IntegrityIssueAction.TrustAsManual); + } + + [Fact] + public async Task VerifyAsyncMarksManualDifferencesForAbsorptionAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + await File.WriteAllTextAsync(Path.Combine(content, "file.txt"), "before"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.Manual); + await service.CaptureSnapshotAsync(target, CancellationToken.None); + await File.WriteAllTextAsync(Path.Combine(content, "file.txt"), "after"); + await File.WriteAllTextAsync(Path.Combine(content, "added.txt"), "added"); + + // Act + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + report.HasManualIssues.Should().BeTrue(); + report.Issues.Should().OnlyContain(issue => issue.Action == IntegrityIssueAction.Absorb); + } + + [Fact] + public async Task CaptureSnapshotAsyncAbsorbsManualDifferencesAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string filePath = Path.Combine(content, "file.txt"); + await File.WriteAllTextAsync(filePath, "before"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.Manual); + await service.CaptureSnapshotAsync(target, CancellationToken.None); + await File.WriteAllTextAsync(filePath, "after"); + + // Act + await service.CaptureSnapshotAsync(target, CancellationToken.None); + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + report.HasIssues.Should().BeFalse(); + } + + [Fact] + public async Task VerifyAsyncPreservesIgnoredInactiveCacheFileAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + await File.WriteAllTextAsync(Path.Combine(content, "active.png"), "active"); + await File.WriteAllTextAsync(Path.Combine(content, "inactive.png"), "inactive"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = new( + "target", + "Target", + content, + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.OrdinalIgnoreCase) { "inactive.png" }); + await service.CaptureSnapshotAsync(target, CancellationToken.None); + + // Act + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + report.HasIssues.Should().BeFalse(); + } + + [Fact] + public async Task MatchesExpectedFileSetAsyncAcceptsFreshManagedCacheAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + await File.WriteAllTextAsync(Path.Combine(content, "active.png"), "active"); + await File.WriteAllTextAsync(Path.Combine(content, "inactive.png"), "inactive"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = new( + "target", + "Target", + content, + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.OrdinalIgnoreCase) { "inactive.png" }); + + // Act + bool matches = await service.MatchesExpectedFileSetAsync( + target, + new HashSet(StringComparer.OrdinalIgnoreCase) { "active.png" }, + CancellationToken.None); + + // Assert + matches.Should().BeTrue(); + } + + [Fact] + public async Task CaptureSnapshotIfMatchesExpectedFileSetAsyncCapturesExistingManagedCacheWithoutMutationAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string activePath = Path.Combine(content, "active.png"); + string inactivePath = Path.Combine(content, "inactive.png"); + await File.WriteAllTextAsync(activePath, "active"); + await File.WriteAllTextAsync(inactivePath, "inactive"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = new( + "target", + "Target", + content, + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.OrdinalIgnoreCase) { "inactive.png" }); + + // Act + bool captured = await service.CaptureSnapshotIfMatchesExpectedFileSetAsync( + target, + new HashSet(StringComparer.OrdinalIgnoreCase) { "active.png" }, + CancellationToken.None); + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + captured.Should().BeTrue(); + report.HasIssues.Should().BeFalse(); + File.ReadAllText(activePath).Should().Be("active"); + File.ReadAllText(inactivePath).Should().Be("inactive"); + } + + [Fact] + public async Task CaptureSnapshotIfMatchesExpectedFileSetAsyncRejectsExtrasWithoutSnapshottingAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + await File.WriteAllTextAsync(Path.Combine(content, "active.png"), "active"); + await File.WriteAllTextAsync(Path.Combine(content, "extra.png"), "extra"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + + // Act + bool captured = await service.CaptureSnapshotIfMatchesExpectedFileSetAsync( + target, + new HashSet(StringComparer.OrdinalIgnoreCase) { "active.png" }, + CancellationToken.None); + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + captured.Should().BeFalse(); + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.Untracked && + issue.Action == IntegrityIssueAction.Repair); + } + + [Fact] + public async Task MatchesExpectedFileSetAsyncRejectsExtraFilesAndEmptyDirectoriesAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(Path.Combine(content, "nested", "empty")); + await File.WriteAllTextAsync(Path.Combine(content, "active.png"), "active"); + await File.WriteAllTextAsync(Path.Combine(content, "extra.png"), "extra"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + + // Act + bool matches = await service.MatchesExpectedFileSetAsync( + target, + new HashSet(StringComparer.OrdinalIgnoreCase) { "active.png" }, + CancellationToken.None); + + // Assert + matches.Should().BeFalse(); + } + + [Fact] + public async Task VerifyAsyncReportsIgnoredUnsafeLinkWithoutFollowingItAsync() + { + // Arrange + using TestDirectory directory = new(); + string outsidePath = Path.Combine(directory.Path, "outside.txt"); + await File.WriteAllTextAsync(outsidePath, "outside"); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string linkPath = Path.Combine(content, "inactive.png"); + try + { + File.CreateSymbolicLink(linkPath, outsidePath); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = new( + "target", + "Target", + content, + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.OrdinalIgnoreCase) { "inactive.png" }); + + // Act + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.UnsafeLink && + issue.Action == IntegrityIssueAction.Delete && + issue.RelativePath == "inactive.png"); + File.ReadAllText(outsidePath).Should().Be("outside"); + } + + [Theory] + [InlineData(ContentSourceKind.ManagedS3, IntegrityIssueAction.Repair)] + [InlineData(ContentSourceKind.ManagedSingleFile, IntegrityIssueAction.Redownload)] + [InlineData(ContentSourceKind.Manual, IntegrityIssueAction.Absorb)] + [InlineData(ContentSourceKind.UnknownLegacy, IntegrityIssueAction.TrustAsManual)] + public async Task VerifyAsyncClassifiesUntrackedContentBySourceAsync( + ContentSourceKind sourceKind, + IntegrityIssueAction expectedAction) + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, sourceKind); + + // Act + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.Untracked && + issue.Action == expectedAction); + } + + [Fact] + public async Task VerifyAsyncRequiresMigrationWhenSourceClassificationChangesAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + await File.WriteAllTextAsync(Path.Combine(content, "file.txt"), "content"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget managedTarget = CreateTarget(content, ContentSourceKind.ManagedS3); + await service.CaptureSnapshotAsync(managedTarget, CancellationToken.None); + ContentIntegrityTarget manualTarget = CreateTarget(content, ContentSourceKind.Manual); + + // Act + ContentIntegrityReport report = await service.VerifyAsync( + new[] { manualTarget }, + CancellationToken.None); + + // Assert + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.Untracked && + issue.Action == IntegrityIssueAction.Absorb); + } + + [Fact] + public async Task ApplyCleanupAsyncDeletesConfirmedManagedExtrasAndEmptyDirectoriesAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + string nested = Path.Combine(content, "nested"); + Directory.CreateDirectory(nested); + await File.WriteAllTextAsync(Path.Combine(nested, "unexpected.txt"), "unexpected"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + target.Id, + target.DisplayName, + target.SourceKind, + IntegrityIssueKind.UnexpectedFile, + IntegrityIssueAction.Delete, + "nested/unexpected.txt"), + }); + + // Act + await service.ApplyCleanupAsync(report, new[] { target }, CancellationToken.None); + + // Assert + File.Exists(Path.Combine(nested, "unexpected.txt")).Should().BeFalse(); + Directory.Exists(nested).Should().BeFalse(); + } + + [Fact] + public async Task ApplyCleanupAsyncDeletesConfirmedDirectoryIssueAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + string unexpected = Path.Combine(content, "unexpected"); + Directory.CreateDirectory(unexpected); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + target.Id, + target.DisplayName, + target.SourceKind, + IntegrityIssueKind.EmptyDirectory, + IntegrityIssueAction.Delete, + "unexpected"), + }); + + // Act + await service.ApplyCleanupAsync(report, new[] { target }, CancellationToken.None); + + // Assert + Directory.Exists(unexpected).Should().BeFalse(); + } + + [Fact] + public async Task ApplyCleanupAsyncRejectsUnknownTargetAsync() + { + // Arrange + using TestDirectory directory = new(); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + "missing", + "Missing", + ContentSourceKind.ManagedS3, + IntegrityIssueKind.UnexpectedFile, + IntegrityIssueAction.Delete, + "unexpected.txt"), + }); + + // Act + Func cleanup = () => service.ApplyCleanupAsync( + report, + Array.Empty(), + CancellationToken.None); + + // Assert + await cleanup.Should().ThrowAsync() + .WithMessage("The cleanup report references an unknown integrity target."); + } + + [Fact] + public async Task ApplyCleanupAsyncIgnoresMissingDeletedEntriesAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + target.Id, + target.DisplayName, + target.SourceKind, + IntegrityIssueKind.UnexpectedFile, + IntegrityIssueAction.Delete, + "missing.txt"), + new ContentIntegrityIssue( + target.Id, + target.DisplayName, + target.SourceKind, + IntegrityIssueKind.UnexpectedFile, + IntegrityIssueAction.Delete, + "missing/missing.txt"), + }); + + // Act + await service.ApplyCleanupAsync(report, new[] { target }, CancellationToken.None); + + // Assert + Directory.Exists(content).Should().BeTrue(); + } + + [Fact] + public async Task ApplyCleanupAsyncSkipsEmptyDirectorySweepWhenRootIsMissingAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "missing-content"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + target.Id, + target.DisplayName, + target.SourceKind, + IntegrityIssueKind.UnexpectedFile, + IntegrityIssueAction.Delete, + "missing.txt"), + }); + + // Act + await service.ApplyCleanupAsync(report, new[] { target }, CancellationToken.None); + + // Assert + Directory.Exists(content).Should().BeFalse(); + } + + [Fact] + public async Task ApplyCleanupAsyncPreservesIgnoredAndNonEmptyDirectoriesAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + string unexpected = Path.Combine(content, "unexpected"); + string ignored = Path.Combine(content, "ignored"); + string nonEmpty = Path.Combine(content, "non-empty"); + Directory.CreateDirectory(unexpected); + Directory.CreateDirectory(ignored); + Directory.CreateDirectory(nonEmpty); + string unexpectedFile = Path.Combine(unexpected, "unexpected.txt"); + await File.WriteAllTextAsync(unexpectedFile, "unexpected"); + await File.WriteAllTextAsync(Path.Combine(nonEmpty, "keep.txt"), "keep"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget( + content, + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.OrdinalIgnoreCase) { "ignored" }); + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + target.Id, + target.DisplayName, + target.SourceKind, + IntegrityIssueKind.UnexpectedFile, + IntegrityIssueAction.Delete, + "unexpected/unexpected.txt"), + }); + + // Act + await service.ApplyCleanupAsync(report, new[] { target }, CancellationToken.None); + + // Assert + File.Exists(unexpectedFile).Should().BeFalse(); + Directory.Exists(unexpected).Should().BeFalse(); + Directory.Exists(ignored).Should().BeTrue(); + Directory.Exists(nonEmpty).Should().BeTrue(); + } + + [Fact] + public async Task VerifyAsyncRejectsManualLinkWithoutFollowingItAsync() + { + // Arrange + using TestDirectory directory = new(); + string outsidePath = Path.Combine(directory.Path, "outside.txt"); + await File.WriteAllTextAsync(outsidePath, "outside"); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string linkPath = Path.Combine(content, "linked.txt"); + try + { + File.CreateSymbolicLink(linkPath, outsidePath); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.Manual); + + // Act + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.Untracked && + issue.Action == IntegrityIssueAction.Absorb); + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.UnsafeLink && + issue.Action == IntegrityIssueAction.Block && + issue.RelativePath == "linked.txt"); + Func capture = () => service.CaptureSnapshotAsync(target, CancellationToken.None); + await capture.Should().ThrowAsync(); + } + + [Fact] + public async Task VerifyAsyncReportsLinkedTargetRootWithoutFollowingItAsync() + { + // Arrange + using TestDirectory directory = new(); + string outside = Path.Combine(directory.Path, "outside"); + Directory.CreateDirectory(outside); + string outsideFile = Path.Combine(outside, "outside.txt"); + await File.WriteAllTextAsync(outsideFile, "outside"); + string content = Path.Combine(directory.Path, "content"); + try + { + Directory.CreateSymbolicLink(content, outside); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + + // Act + ContentIntegrityReport report = await service.VerifyAsync( + new[] { target }, + CancellationToken.None); + + // Assert + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.UnsafeLink && + issue.Action == IntegrityIssueAction.Delete && + issue.RelativePath == "."); + File.ReadAllText(outsideFile).Should().Be("outside"); + } + + [Fact] + public async Task ApplyCleanupAsyncDeletesLinkedTargetRootWithoutDeletingTargetAsync() + { + // Arrange + using TestDirectory directory = new(); + string outside = Path.Combine(directory.Path, "outside"); + Directory.CreateDirectory(outside); + string outsideFile = Path.Combine(outside, "outside.txt"); + await File.WriteAllTextAsync(outsideFile, "outside"); + string content = Path.Combine(directory.Path, "content"); + try + { + Directory.CreateSymbolicLink(content, outside); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + target.Id, + target.DisplayName, + target.SourceKind, + IntegrityIssueKind.UnsafeLink, + IntegrityIssueAction.Delete, + "."), + }); + + // Act + await service.ApplyCleanupAsync(report, new[] { target }, CancellationToken.None); + + // Assert + Directory.Exists(content).Should().BeFalse(); + File.ReadAllText(outsideFile).Should().Be("outside"); + } + + [Fact] + public async Task ApplyCleanupAsyncRejectsPathTraversalAsync() + { + // Arrange + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(content); + string outsideFile = Path.Combine(directory.Path, "outside.txt"); + await File.WriteAllTextAsync(outsideFile, "outside"); + FileSystemContentIntegrityService service = CreateService(directory.Path); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + target.Id, + target.DisplayName, + target.SourceKind, + IntegrityIssueKind.UnexpectedFile, + IntegrityIssueAction.Delete, + "../outside.txt"), + }); + + // Act + Func cleanup = () => service.ApplyCleanupAsync( + report, + new[] { target }, + CancellationToken.None); + + // Assert + await cleanup.Should().ThrowAsync(); + File.ReadAllText(outsideFile).Should().Be("outside"); + } + + private static FileSystemContentIntegrityService CreateService(string root) + { + return new FileSystemContentIntegrityService( + Path.Combine(root, "snapshots"), + NullLogger.Instance); + } + + private static ContentIntegrityTarget CreateTarget( + string root, + ContentSourceKind sourceKind, + IReadOnlySet? ignoredRelativePaths = null) + { + return new ContentIntegrityTarget( + "target", + "Target", + root, + sourceKind, + ignoredRelativePaths ?? new HashSet(StringComparer.OrdinalIgnoreCase)); + } + + private static string GetSnapshotPath(string snapshotDirectory, string targetId) + { + byte[] identifierHash = SHA256.HashData(Encoding.UTF8.GetBytes(targetId)); + return Path.Combine(snapshotDirectory, Convert.ToHexString(identifierHash) + ".json"); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Composition/DeploymentServiceCollectionExtensionsTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Composition/DeploymentServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..5859a0f9 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Composition/DeploymentServiceCollectionExtensionsTests.cs @@ -0,0 +1,74 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Launching.Composition; +using GenLauncherGO.Infrastructure.Launching.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Composition; + +public sealed class DeploymentServiceCollectionExtensionsTests +{ + [Fact] + public void AddGenLauncherGoDeploymentReturnsSameServiceCollection() + { + // Arrange + ServiceCollection services = new(); + + // Act + IServiceCollection returnedServices = services.AddGenLauncherGoDeployment(); + + // Assert + returnedServices.Should().BeSameAs(services); + } + + [Fact] + public void AddGenLauncherGoDeploymentThrowsForNullServices() + { + // Arrange + IServiceCollection? services = null; + + // Act + Action act = () => services!.AddGenLauncherGoDeployment(); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void AddGenLauncherGoDeploymentRegistersExecutableDiscoveryService() + { + // Arrange + using var directory = new TestDirectory(); + ServiceCollection services = new(); + services.AddLogging(); + services.AddSingleton(CreatePaths(directory.Path)); + + // Act + services.AddGenLauncherGoDeployment(); + using ServiceProvider provider = services.BuildServiceProvider(); + + // Assert + provider.GetRequiredService() + .Should().BeOfType(); + } + + private static LauncherPaths CreatePaths(string root) + { + string gameDirectory = Path.Combine(root, "Game"); + string launcherDirectory = Path.Combine(gameDirectory, "GenLauncherGO"); + + return new LauncherPaths( + gameDirectory, + launcherDirectory, + Path.Combine(launcherDirectory, "Runtime"), + Path.Combine(launcherDirectory, "Runtime", "Cache"), + Path.Combine(launcherDirectory, "Runtime", "Cache", "Images"), + Path.Combine(launcherDirectory, "Mods"), + Path.Combine(launcherDirectory, "Logs"), + Path.Combine(launcherDirectory, "Runtime", "Temp"), + Path.Combine(launcherDirectory, "Runtime", "Deployment")); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/DeploymentLaunchPreparationServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/DeploymentLaunchPreparationServiceTests.cs new file mode 100644 index 00000000..6b304d49 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/DeploymentLaunchPreparationServiceTests.cs @@ -0,0 +1,175 @@ +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Launching.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class DeploymentLaunchPreparationServiceTests +{ + [Fact] + public async Task PrepareAsyncMapsSelectedVersionsToDeploymentPackagesAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var deploymentService = new RecordingDeploymentService(); + DeploymentLaunchPreparationService service = CreateService(deploymentService); + ModificationVersion[] versions = new[] + { + CreateVersion(ModificationType.Mod, "Rise", "1.0"), + CreateVersion(ModificationType.Patch, "Balance", "2.0", "Rise"), + CreateVersion(ModificationType.Addon, "Maps", "3.0", "Rise"), + }; + + // Act + LaunchPreparationResult result = await service.PrepareAsync( + new LaunchPreparationRequest(paths, versions, "Addons", "Patches"), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + deploymentService.PrepareRequest.Should().NotBeNull(); + IReadOnlyList packages = deploymentService.PrepareRequest!.Packages; + packages.Should().HaveCount(3); + packages[0].Kind.Should().Be(DeploymentPackageKind.Mod); + packages[0].RootDirectory.Should().Be(Path.Combine(paths.ModsDirectory, "Rise", "1.0")); + packages[0].Precedence.Should().Be(0); + packages[1].Kind.Should().Be(DeploymentPackageKind.Patch); + packages[1].RootDirectory.Should().Be(Path.Combine(paths.ModsDirectory, "Rise", "Patches", "Balance", "2.0")); + packages[1].Precedence.Should().Be(1); + packages[2].Kind.Should().Be(DeploymentPackageKind.Addon); + packages[2].RootDirectory.Should().Be(Path.Combine(paths.ModsDirectory, "Rise", "Addons", "Maps", "3.0")); + packages[2].Precedence.Should().Be(2); + deploymentService.PrepareRequest.DisabledTargetRelativePaths.Should().BeEmpty(); + } + + [Fact] + public async Task PrepareAsyncMapsBaseGameScriptDisableFlagToDeploymentTargetsAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var deploymentService = new RecordingDeploymentService(); + DeploymentLaunchPreparationService service = CreateService(deploymentService); + + // Act + LaunchPreparationResult result = await service.PrepareAsync( + new LaunchPreparationRequest( + paths, + new[] { CreateVersion(ModificationType.Mod, "Rise", "1.0") }, + "Addons", + "Patches", + disableBaseGameScriptFiles: true), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + deploymentService.PrepareRequest.Should().NotBeNull(); + deploymentService.PrepareRequest!.DisabledTargetRelativePaths.Should().BeEquivalentTo( + "Data/Scripts/MultiplayerScripts.scb", + "Data/Scripts/SkirmishScripts.scb", + "Data/Scripts/Scripts.ini"); + } + + [Fact] + public async Task CleanupAndRecoveryDelegateToDeploymentServiceAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var deploymentService = new RecordingDeploymentService(); + DeploymentLaunchPreparationService service = CreateService(deploymentService); + + // Act + LaunchPreparationResult cleanupResult = await service.CleanupAsync(paths, CancellationToken.None); + LaunchPreparationResult recoveryResult = await service.RecoverAsync(paths, CancellationToken.None); + + // Assert + cleanupResult.Succeeded.Should().BeTrue(); + recoveryResult.Succeeded.Should().BeTrue(); + deploymentService.CleanupRequest.Should().NotBeNull(); + deploymentService.CleanupRequest!.Paths.Should().Be(paths); + deploymentService.RecoveryRequest.Should().NotBeNull(); + deploymentService.RecoveryRequest!.Paths.Should().Be(paths); + } + + private static DeploymentLaunchPreparationService CreateService(RecordingDeploymentService deploymentService) + { + return new DeploymentLaunchPreparationService( + deploymentService, + NullLogger.Instance); + } + + private static ModificationVersion CreateVersion( + ModificationType modificationType, + string name, + string version, + string dependenceName = "") + { + return new ModificationVersion + { + ModificationType = modificationType, + Name = name, + Version = version, + DependenceName = dependenceName, + }; + } + + private static LauncherPaths CreatePaths(string root) + { + string gameDirectory = Path.Combine(root, "Game"); + string launcherDirectory = Path.Combine(gameDirectory, "GenLauncherGO"); + + return new LauncherPaths( + gameDirectory, + launcherDirectory, + Path.Combine(launcherDirectory, "Runtime"), + Path.Combine(launcherDirectory, "Runtime", "Cache"), + Path.Combine(launcherDirectory, "Runtime", "Cache", "Images"), + Path.Combine(launcherDirectory, "Mods"), + Path.Combine(launcherDirectory, "Logs"), + Path.Combine(launcherDirectory, "Runtime", "Temp"), + Path.Combine(launcherDirectory, "Runtime", "Deployment")); + } + + private sealed class RecordingDeploymentService : IDeploymentService + { + public DeploymentRequest? PrepareRequest { get; private set; } + + public DeploymentCleanupRequest? CleanupRequest { get; private set; } + + public DeploymentRecoveryRequest? RecoveryRequest { get; private set; } + + public Task PrepareAsync( + DeploymentRequest request, + CancellationToken cancellationToken) + { + PrepareRequest = request; + return Task.FromResult(DeploymentResult.Success()); + } + + public Task CleanupAsync( + DeploymentCleanupRequest request, + CancellationToken cancellationToken) + { + CleanupRequest = request; + return Task.FromResult(DeploymentResult.Success()); + } + + public Task RecoverAsync( + DeploymentRecoveryRequest request, + CancellationToken cancellationToken) + { + RecoveryRequest = request; + return Task.FromResult(DeploymentResult.Success()); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemDeploymentServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemDeploymentServiceTests.cs new file mode 100644 index 00000000..58a1a881 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemDeploymentServiceTests.cs @@ -0,0 +1,727 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Launching.Services; +using GenLauncherGO.Infrastructure.Launching.Support; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class FileSystemDeploymentServiceTests +{ + [Fact] + public async Task PrepareAsyncUsesHardLinkWhenCreatorSucceedsAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FakeHardLinkCreator hardLinks = new(canCreate: true); + FileSystemDeploymentService service = CreateService(hardLinks); + + // Act + DeploymentResult result = await service.PrepareAsync( + new DeploymentRequest(paths, new[] { CreatePackage(packageRoot, DeploymentPackageKind.Mod, 0) }), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + result.Manifest!.Files.Should().ContainSingle().Which.Method.Should().Be(DeploymentMethod.HardLink); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().Be("mod"); + hardLinks.CreatedLinks.Should().ContainSingle(); + } + + [Fact] + public async Task PrepareAsyncCopiesFileWhenHardLinkCreatorFailsAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.PrepareAsync( + new DeploymentRequest(paths, new[] { CreatePackage(packageRoot, DeploymentPackageKind.Mod, 0) }), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + result.Manifest!.Files.Should().ContainSingle().Which.Method.Should().Be(DeploymentMethod.Copy); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().Be("mod"); + } + + [Fact] + public async Task CleanupAsyncRestoresBackedUpOriginalFileAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + Directory.CreateDirectory(Path.Combine(paths.GameDirectory, "Data")); + File.WriteAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini"), "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + await service.PrepareAsync( + new DeploymentRequest(paths, new[] { CreatePackage(packageRoot, DeploymentPackageKind.Mod, 0) }), + CancellationToken.None); + + // Act + DeploymentResult result = await service.CleanupAsync( + new DeploymentCleanupRequest(paths), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().Be("original"); + Directory.Exists(Path.Combine(paths.DeploymentDirectory, "Backups")).Should().BeFalse(); + } + + [Fact] + public async Task CleanupAsyncDoesNotDeleteRestoredOriginalWhenManifestWasAlreadyAppliedAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + await service.PrepareAsync( + new DeploymentRequest(paths, new[] { CreatePackage(packageRoot, DeploymentPackageKind.Mod, 0) }), + CancellationToken.None); + string activeManifestPath = Path.Combine(paths.DeploymentDirectory, "active.json"); + string staleManifest = File.ReadAllText(activeManifestPath); + await service.CleanupAsync( + new DeploymentCleanupRequest(paths), + CancellationToken.None); + File.WriteAllText(activeManifestPath, staleManifest); + + // Act + DeploymentResult result = await service.CleanupAsync( + new DeploymentCleanupRequest(paths), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + } + + [Fact] + public async Task CleanupAsyncRemovesCreatedDirectoriesOnlyWhenEmptyAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string packageRoot = CreatePackage(paths, "Mod", ("Data/Sub/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + await service.PrepareAsync( + new DeploymentRequest(paths, new[] { CreatePackage(packageRoot, DeploymentPackageKind.Mod, 0) }), + CancellationToken.None); + File.WriteAllText(Path.Combine(paths.GameDirectory, "Data", "keep.txt"), "user"); + + // Act + DeploymentResult result = await service.CleanupAsync( + new DeploymentCleanupRequest(paths), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + Directory.Exists(Path.Combine(paths.GameDirectory, "Data", "Sub")).Should().BeFalse(); + Directory.Exists(Path.Combine(paths.GameDirectory, "Data")).Should().BeTrue(); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "keep.txt")).Should().Be("user"); + } + + [Fact] + public async Task PrepareAsyncDeploysGibSourceAsBigTargetAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string packageRoot = CreatePackage(paths, "Mod", ("PatchData.gib", "archive")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.PrepareAsync( + new DeploymentRequest(paths, new[] { CreatePackage(packageRoot, DeploymentPackageKind.Mod, 0) }), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + File.Exists(Path.Combine(paths.GameDirectory, "PatchData.big")).Should().BeTrue(); + File.Exists(Path.Combine(paths.GameDirectory, "PatchData.gib")).Should().BeFalse(); + } + + [Fact] + public async Task PrepareAsyncLetsHigherPrecedencePackageWinTargetConflictAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string modRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + string addonRoot = CreatePackage(paths, "Addon", ("Data/file.ini", "addon")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.PrepareAsync( + new DeploymentRequest( + paths, + new[] + { + CreatePackage(modRoot, DeploymentPackageKind.Mod, 0), + CreatePackage(addonRoot, DeploymentPackageKind.Addon, 1) + }), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().Be("addon"); + result.Manifest!.Files.Should().ContainSingle().Which.PackageId.Should().Contain("Addon"); + } + + [Fact] + public async Task PrepareAsyncDisablesExistingRequestedFilesAndCleanupRestoresThemAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string scriptsDirectory = Path.Combine(paths.GameDirectory, "Data", "Scripts"); + Directory.CreateDirectory(scriptsDirectory); + string multiplayerScripts = Path.Combine(scriptsDirectory, "MultiplayerScripts.scb"); + string scriptsIni = Path.Combine(scriptsDirectory, "Scripts.ini"); + File.WriteAllText(multiplayerScripts, "multiplayer"); + File.WriteAllText(scriptsIni, "scripts"); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult prepareResult = await service.PrepareAsync( + new DeploymentRequest( + paths, + Array.Empty(), + new[] + { + "Data/Scripts/MultiplayerScripts.scb", + "Data/Scripts/SkirmishScripts.scb", + "Data/Scripts/Scripts.ini" + }), + CancellationToken.None); + + // Assert + prepareResult.Succeeded.Should().BeTrue(); + prepareResult.Manifest!.Files.Should().HaveCount(2); + File.Exists(multiplayerScripts).Should().BeFalse(); + File.Exists(Path.Combine(scriptsDirectory, "SkirmishScripts.scb")).Should().BeFalse(); + File.Exists(scriptsIni).Should().BeFalse(); + + DeploymentResult cleanupResult = await service.CleanupAsync( + new DeploymentCleanupRequest(paths), + CancellationToken.None); + + cleanupResult.Succeeded.Should().BeTrue(); + File.ReadAllText(multiplayerScripts).Should().Be("multiplayer"); + File.ReadAllText(scriptsIni).Should().Be("scripts"); + } + + [Fact] + public async Task PrepareAsyncReusesDisabledFileBackupWhenPackageDeploysSameTargetAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string scriptsDirectory = Path.Combine(paths.GameDirectory, "Data", "Scripts"); + Directory.CreateDirectory(scriptsDirectory); + string scriptsIni = Path.Combine(scriptsDirectory, "Scripts.ini"); + File.WriteAllText(scriptsIni, "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/Scripts/Scripts.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult prepareResult = await service.PrepareAsync( + new DeploymentRequest( + paths, + new[] { CreatePackage(packageRoot, DeploymentPackageKind.Mod, 0) }, + new[] { "Data/Scripts/Scripts.ini" }), + CancellationToken.None); + + // Assert + prepareResult.Succeeded.Should().BeTrue(); + prepareResult.Manifest!.Files.Should().ContainSingle().Which.BackupRelativePath.Should().NotBeNull(); + File.ReadAllText(scriptsIni).Should().Be("mod"); + + DeploymentResult cleanupResult = await service.CleanupAsync( + new DeploymentCleanupRequest(paths), + CancellationToken.None); + + cleanupResult.Succeeded.Should().BeTrue(); + File.ReadAllText(scriptsIni).Should().Be("original"); + } + + [Fact] + public async Task PrepareAsyncRecoversPartialDeploymentWhenLaterFileFailsAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + Directory.CreateDirectory(Path.Combine(paths.GameDirectory, "A")); + File.WriteAllText(Path.Combine(paths.GameDirectory, "A", "file.ini"), "original"); + File.WriteAllText(Path.Combine(paths.GameDirectory, "B"), "not-a-directory"); + string packageRoot = CreatePackage( + paths, + "Mod", + ("A/file.ini", "mod"), + ("B/file.ini", "blocked")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.PrepareAsync( + new DeploymentRequest(paths, new[] { CreatePackage(packageRoot, DeploymentPackageKind.Mod, 0) }), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeFalse(); + File.ReadAllText(Path.Combine(paths.GameDirectory, "A", "file.ini")).Should().Be("original"); + File.ReadAllText(Path.Combine(paths.GameDirectory, "B")).Should().Be("not-a-directory"); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeFalse(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public async Task PrepareAsyncFailsWhenDeploymentLockIsHeldAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string deploymentRoot = paths.DeploymentDirectory; + Directory.CreateDirectory(deploymentRoot); + using FileStream lockStream = new( + Path.Combine(deploymentRoot, "deployment.lock"), + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.PrepareAsync( + new DeploymentRequest(paths, new[] { CreatePackage(packageRoot, DeploymentPackageKind.Mod, 0) }), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeFalse(); + File.Exists(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().BeFalse(); + } + + [Fact] + public async Task PrepareAsyncFailsWhenPackageTreeContainsReparsePointAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string packageRoot = Path.Combine(paths.ModsDirectory, "Mod"); + string linkTarget = Path.Combine(directory.Path, "linked-package-content"); + string linkPath = Path.Combine(packageRoot, "Linked"); + Directory.CreateDirectory(packageRoot); + Directory.CreateDirectory(linkTarget); + if (!TryCreateDirectoryLink(linkPath, linkTarget)) + { + return; + } + + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.PrepareAsync( + new DeploymentRequest(paths, new[] { CreatePackage(packageRoot, DeploymentPackageKind.Mod, 0) }), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeFalse(); + } + + [Fact] + public async Task PrepareAsyncFailsWhenGameTargetParentIsReparsePointAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + string linkedTarget = Path.Combine(directory.Path, "outside-game-data"); + string linkedDataDirectory = Path.Combine(paths.GameDirectory, "Data"); + Directory.CreateDirectory(linkedTarget); + if (!TryCreateDirectoryLink(linkedDataDirectory, linkedTarget)) + { + return; + } + + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.PrepareAsync( + new DeploymentRequest(paths, new[] { CreatePackage(packageRoot, DeploymentPackageKind.Mod, 0) }), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeFalse(); + File.Exists(Path.Combine(linkedTarget, "file.ini")).Should().BeFalse(); + } + + [Fact] + public async Task CleanupAsyncRestoresBackedUpFileFromJournalWithoutActiveManifestAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string deploymentRoot = paths.DeploymentDirectory; + string backupPath = Path.Combine(deploymentRoot, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + File.WriteAllText(backupPath, "original"); + File.WriteAllText( + Path.Combine(deploymentRoot, "journal.jsonl"), + "{\"action\":\"file-backed-up\",\"targetRelativePath\":\"Data/file.ini\",\"backupRelativePath\":\"Backups/crash/Data/file.ini\"}"); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.CleanupAsync( + new DeploymentCleanupRequest(paths), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().Be("original"); + File.Exists(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public async Task RecoverAsyncRestoresBackupStartedFileWhenMoveCompletedBeforeBackedUpJournalAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, "original"); + string deploymentRoot = paths.DeploymentDirectory; + string backupPath = Path.Combine(deploymentRoot, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + File.WriteAllText( + Path.Combine(deploymentRoot, "journal.jsonl"), + "{\"action\":\"file-backup-started\",\"targetRelativePath\":\"Data/file.ini\",\"backupRelativePath\":\"Backups/crash/Data/file.ini\"}"); + File.Move(targetPath, backupPath); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.RecoverAsync( + new DeploymentRecoveryRequest(paths), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + File.Exists(backupPath).Should().BeFalse(); + File.Exists(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public async Task RecoverAsyncIgnoresBackupStartedRecordWhenBackupWasNotCreatedAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, "original"); + string deploymentRoot = paths.DeploymentDirectory; + Directory.CreateDirectory(deploymentRoot); + File.WriteAllText( + Path.Combine(deploymentRoot, "journal.jsonl"), + "{\"action\":\"file-backup-started\",\"targetRelativePath\":\"Data/file.ini\",\"backupRelativePath\":\"Backups/crash/Data/file.ini\"}"); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.RecoverAsync( + new DeploymentRecoveryRequest(paths), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + File.Exists(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public async Task RecoverAsyncRestoresBackupWhenCleanupRestoreStartedAndBackupStillExistsAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, "mod"); + string deploymentRoot = paths.DeploymentDirectory; + string backupPath = Path.Combine(deploymentRoot, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + File.WriteAllText(backupPath, "original"); + WriteJournal( + deploymentRoot, + "{\"action\":\"file-backed-up\",\"targetRelativePath\":\"Data/file.ini\",\"backupRelativePath\":\"Backups/crash/Data/file.ini\"}", + "{\"action\":\"file-deployed\",\"targetRelativePath\":\"Data/file.ini\",\"backupRelativePath\":\"Backups/crash/Data/file.ini\",\"sourcePath\":\"source\",\"packageId\":\"Mod\"}", + "{\"action\":\"file-cleanup-restore-started\",\"targetRelativePath\":\"Data/file.ini\",\"backupRelativePath\":\"Backups/crash/Data/file.ini\"}"); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.RecoverAsync( + new DeploymentRecoveryRequest(paths), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + File.Exists(backupPath).Should().BeFalse(); + File.Exists(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public async Task RecoverAsyncTreatsMissingBackupAfterCleanupRestoreStartedAsRestoredAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, "original"); + string deploymentRoot = paths.DeploymentDirectory; + Directory.CreateDirectory(deploymentRoot); + File.WriteAllText(Path.Combine(deploymentRoot, "active.json"), "{not-json"); + WriteJournal( + deploymentRoot, + "{\"action\":\"file-backed-up\",\"targetRelativePath\":\"Data/file.ini\",\"backupRelativePath\":\"Backups/crash/Data/file.ini\"}", + "{\"action\":\"file-deployed\",\"targetRelativePath\":\"Data/file.ini\",\"backupRelativePath\":\"Backups/crash/Data/file.ini\",\"sourcePath\":\"source\",\"packageId\":\"Mod\"}", + "{\"action\":\"file-cleanup-restore-started\",\"targetRelativePath\":\"Data/file.ini\",\"backupRelativePath\":\"Backups/crash/Data/file.ini\"}"); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.RecoverAsync( + new DeploymentRecoveryRequest(paths), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + File.Exists(Path.Combine(deploymentRoot, "active.json")).Should().BeFalse(); + File.Exists(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public async Task RecoverAsyncKeepsNoBackupFileDeletedWhenCleanupDeleteCompletedAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + string deploymentRoot = paths.DeploymentDirectory; + Directory.CreateDirectory(deploymentRoot); + File.WriteAllText(Path.Combine(deploymentRoot, "active.json"), "{not-json"); + WriteJournal( + deploymentRoot, + "{\"action\":\"file-deployed\",\"targetRelativePath\":\"Data/file.ini\",\"sourcePath\":\"source\",\"packageId\":\"Mod\"}", + "{\"action\":\"file-cleanup-delete-completed\",\"targetRelativePath\":\"Data/file.ini\"}"); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.RecoverAsync( + new DeploymentRecoveryRequest(paths), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + File.Exists(targetPath).Should().BeFalse(); + File.Exists(Path.Combine(deploymentRoot, "active.json")).Should().BeFalse(); + File.Exists(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public async Task RecoverAsyncRestoresBackedUpFileFromJournalWithoutActiveManifestAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string deploymentRoot = paths.DeploymentDirectory; + string backupPath = Path.Combine(deploymentRoot, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + File.WriteAllText(backupPath, "original"); + File.WriteAllText( + Path.Combine(deploymentRoot, "journal.jsonl"), + "{\"action\":\"file-backed-up\",\"targetRelativePath\":\"Data/file.ini\",\"backupRelativePath\":\"Backups/crash/Data/file.ini\"}"); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.RecoverAsync( + new DeploymentRecoveryRequest(paths), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().Be("original"); + File.Exists(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public async Task RecoverAsyncFallsBackToJournalWhenActiveManifestIsCorruptAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string deployedPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, "mod"); + string deploymentRoot = paths.DeploymentDirectory; + Directory.CreateDirectory(deploymentRoot); + File.WriteAllText(Path.Combine(deploymentRoot, "active.json"), "{not-json"); + File.WriteAllText( + Path.Combine(deploymentRoot, "journal.jsonl"), + "{\"action\":\"file-deployed\",\"targetRelativePath\":\"Data/file.ini\",\"sourcePath\":\"source\",\"packageId\":\"Mod\"}"); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.RecoverAsync( + new DeploymentRecoveryRequest(paths), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + File.Exists(deployedPath).Should().BeFalse(); + File.Exists(Path.Combine(deploymentRoot, "active.json")).Should().BeFalse(); + File.Exists(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public async Task RecoverAsyncRemovesFileFromStartedJournalRecordAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string deployedPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, "mod"); + string deploymentRoot = paths.DeploymentDirectory; + Directory.CreateDirectory(deploymentRoot); + File.WriteAllText( + Path.Combine(deploymentRoot, "journal.jsonl"), + "{\"action\":\"file-deployment-started\",\"targetRelativePath\":\"Data/file.ini\",\"sourcePath\":\"source\",\"packageId\":\"Mod\"}"); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator(canCreate: false)); + + // Act + DeploymentResult result = await service.RecoverAsync( + new DeploymentRecoveryRequest(paths), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + File.Exists(deployedPath).Should().BeFalse(); + File.Exists(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + private static FileSystemDeploymentService CreateService(IHardLinkCreator hardLinkCreator) + { + return new FileSystemDeploymentService( + hardLinkCreator, + NullLogger.Instance); + } + + private static LauncherPaths CreatePaths(string root) + { + string gameDirectory = Path.Combine(root, "Game"); + string launcherDirectory = Path.Combine(gameDirectory, "GenLauncherGO"); + Directory.CreateDirectory(gameDirectory); + Directory.CreateDirectory(launcherDirectory); + + return new LauncherPaths( + gameDirectory, + launcherDirectory, + Path.Combine(launcherDirectory, "Runtime"), + Path.Combine(launcherDirectory, "Runtime", "Cache"), + Path.Combine(launcherDirectory, "Runtime", "Cache", "Images"), + Path.Combine(launcherDirectory, "Mods"), + Path.Combine(launcherDirectory, "Logs"), + Path.Combine(launcherDirectory, "Runtime", "Temp"), + Path.Combine(launcherDirectory, "Runtime", "Deployment")); + } + + private static string CreatePackage( + LauncherPaths paths, + string name, + params (string RelativePath, string Contents)[] files) + { + string packageRoot = Path.Combine(paths.ModsDirectory, name); + foreach ((string relativePath, string contents) in files) + { + string filePath = Path.Combine(packageRoot, relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + File.WriteAllText(filePath, contents); + } + + return packageRoot; + } + + private static void WriteJournal(string deploymentRoot, params string[] records) + { + Directory.CreateDirectory(deploymentRoot); + File.WriteAllLines(Path.Combine(deploymentRoot, "journal.jsonl"), records); + } + + private static DeploymentPackage CreatePackage( + string root, + DeploymentPackageKind kind, + int precedence) + { + string id = $"{kind}:{Path.GetFileName(root)}"; + return new DeploymentPackage( + id, + Path.GetFileName(root), + kind, + root, + precedence); + } + + private static bool TryCreateDirectoryLink(string linkPath, string targetPath) + { + try + { + Directory.CreateSymbolicLink(linkPath, targetPath); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return false; + } + } + + private sealed class FakeHardLinkCreator : IHardLinkCreator + { + private readonly bool _canCreate; + + public FakeHardLinkCreator(bool canCreate) + { + _canCreate = canCreate; + } + + public List<(string TargetPath, string SourcePath)> CreatedLinks { get; } = new(); + + public bool TryCreateHardLink(string targetPath, string sourcePath) + { + if (!_canCreate) + { + return false; + } + + File.Copy(sourcePath, targetPath); + CreatedLinks.Add((targetPath, sourcePath)); + return true; + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionServiceTests.cs new file mode 100644 index 00000000..9dca5e28 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionServiceTests.cs @@ -0,0 +1,542 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Contracts; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Remote; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Launching.Services; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class FileSystemLaunchContentIntegrityResolutionServiceTests +{ + [Fact] + public async Task VerifyAsync_BuildsTargetsAndVerifiesThemAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + LauncherPaths paths = CreatePaths(testDirectory.Path); + ModificationVersion version = CreateVersion(ContentSourceKind.ManagedSingleFile); + ContentIntegrityTarget target = CreateTarget("package", Path.Combine(paths.ModsDirectory, "ShockWave", "1.2")); + LaunchContentIntegrityTargetContext[] contexts = + [ + new LaunchContentIntegrityTargetContext(target, version, isCache: false), + ]; + var report = new ContentIntegrityReport(Array.Empty()); + IContentIntegrityService integrityService = CreateIntegrityService(); + integrityService.VerifyAsync( + Arg.Any>(), + Arg.Any()) + .Returns(Task.FromResult(report)); + ILaunchContentIntegrityTargetBuilder targetBuilder = Substitute.For(); + targetBuilder.BuildTargets(Arg.Any()).Returns(contexts); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + targetBuilder); + var request = new LaunchContentIntegrityTargetRequest( + paths, + new[] { version }, + new[] { version }, + "cache"); + + // Act + LaunchContentIntegrityVerificationResult result = await service.VerifyAsync( + request, + CancellationToken.None); + + // Assert + result.Report.Should().BeSameAs(report); + result.TargetContexts.Should().Equal(contexts); + await integrityService.Received(1).VerifyAsync( + Arg.Is>(targets => + targets.Count == 1 && + targets[0] == target), + Arg.Any()); + } + + [Fact] + public async Task InitializeUntrackedManagedCachesAsync_CapturesCacheWhenExpectedAssetsMatchAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + LauncherPaths paths = CreatePaths(testDirectory.Path); + ModificationVersion version = CreateVersion(ContentSourceKind.ManagedSingleFile); + ContentIntegrityTarget cacheTarget = CreateTarget( + "cache", + paths.GetModificationImagesDirectory("ShockWave"), + ContentSourceKind.ManagedSingleFile); + LaunchContentIntegrityTargetContext cacheContext = new(cacheTarget, version, isCache: true); + ContentIntegrityReport report = CreateReport( + "cache", + ContentSourceKind.ManagedSingleFile, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.Redownload); + IContentIntegrityService integrityService = CreateIntegrityService(); + integrityService.CaptureSnapshotIfMatchesExpectedFileSetAsync( + cacheTarget, + Arg.Any>(), + Arg.Any()) + .Returns(Task.FromResult(true)); + FileSystemLaunchContentIntegrityResolutionService service = CreateService(integrityService); + + // Act + bool initialized = await service.InitializeUntrackedManagedCachesAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { cacheContext }), + CancellationToken.None); + + // Assert + initialized.Should().BeTrue(); + await integrityService.Received(1).CaptureSnapshotIfMatchesExpectedFileSetAsync( + cacheTarget, + Arg.Is>(relativePaths => + relativePaths.Count == 2 && + relativePaths.Contains("1.2.jpg") && + relativePaths.Contains("1.2-background.png")), + Arg.Any()); + } + + [Fact] + public async Task ResolveAsync_RefreshesManagedCacheAndPreservesIgnoredFilesAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + LauncherPaths paths = CreatePaths(testDirectory.Path); + ModificationVersion version = CreateVersion(ContentSourceKind.ManagedSingleFile); + string cacheRoot = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(cacheRoot); + string staleFilePath = Path.Combine(cacheRoot, "stale.txt"); + string ignoredFilePath = Path.Combine(cacheRoot, "ignored.txt"); + await File.WriteAllTextAsync(staleFilePath, "stale"); + await File.WriteAllTextAsync(ignoredFilePath, "ignored"); + + ContentIntegrityTarget cacheTarget = CreateTarget( + "cache", + cacheRoot, + ContentSourceKind.ManagedSingleFile, + new HashSet(StringComparer.OrdinalIgnoreCase) { "ignored.txt" }); + LaunchContentIntegrityTargetContext cacheContext = new(cacheTarget, version, isCache: true); + ContentIntegrityReport report = CreateReport( + "cache", + ContentSourceKind.ManagedSingleFile, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.Repair); + IContentIntegrityService integrityService = CreateIntegrityService(); + IRemoteAssetDownloader assetDownloader = Substitute.For(); + assetDownloader.DownloadIfMissingAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.CompletedTask); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + assetDownloader: assetDownloader); + RecordingResolutionProgress progress = new(); + + // Act + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { cacheContext }), + progress, + CancellationToken.None); + + // Assert + File.Exists(staleFilePath).Should().BeFalse(); + File.Exists(ignoredFilePath).Should().BeTrue(); + await assetDownloader.Received(1).DownloadIfMissingAsync( + new Uri("https://cdn.example.test/card.jpg"), + Path.Combine(cacheRoot, "1.2.jpg"), + Arg.Any()); + await assetDownloader.Received(1).DownloadIfMissingAsync( + new Uri("https://cdn.example.test/background.bmp"), + Path.Combine(cacheRoot, "1.2-background.png"), + Arg.Any()); + progress.Reports.Should().ContainSingle(report => + report.TargetId == "cache" && + report.Completed && + report.PackageProgress == null); + await integrityService.Received(1).CaptureSnapshotAsync(cacheTarget, Arg.Any()); + } + + [Fact] + public async Task ResolveAsync_RepairsManagedSingleFilePackageAndReportsProgressAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + LauncherPaths paths = CreatePaths(testDirectory.Path); + ModificationVersion version = CreateVersion(ContentSourceKind.ManagedSingleFile); + ContentIntegrityTarget packageTarget = CreateTarget( + "package", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"), + ContentSourceKind.ManagedSingleFile); + LaunchContentIntegrityTargetContext packageContext = new(packageTarget, version, isCache: false); + ContentIntegrityReport report = CreateReport( + "package", + ContentSourceKind.ManagedSingleFile, + IntegrityIssueKind.MissingFile, + IntegrityIssueAction.Repair); + PackageUpdateProgress packageProgress = new(100, 40, 40, "package.zip"); + SingleFilePackageUpdateRequest? updateRequest = null; + ISingleFilePackageUpdater singleFilePackageUpdater = Substitute.For(); + singleFilePackageUpdater.UpdateAsync( + Arg.Any(), + Arg.Any>(), + Arg.Any()) + .Returns(call => + { + updateRequest = call.ArgAt(0); + call.ArgAt>(1).Report(packageProgress); + return Task.CompletedTask; + }); + IContentIntegrityService integrityService = CreateIntegrityService(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + singleFilePackageUpdater: singleFilePackageUpdater); + RecordingResolutionProgress progress = new(); + + // Act + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { packageContext }), + progress, + CancellationToken.None); + + // Assert + updateRequest.Should().NotBeNull(); + updateRequest!.SourceUri.Should().Be(new Uri("https://www.dropbox.com/s/package/file.zip?dl=1")); + updateRequest.TemporaryFolderPath.Should().Be(Path.Combine(paths.TempDirectory, "Packages", "ShockWave", "1.2")); + updateRequest.InstalledFolderPath.Should().Be(packageTarget.RootDirectory); + progress.Reports.Should().ContainSingle(report => + report.TargetId == "package" && + report.PackageProgress == packageProgress && + !report.Completed); + await integrityService.Received(1).CaptureSnapshotAsync(packageTarget, Arg.Any()); + } + + [Fact] + public async Task ResolveAsync_RepairsManagedS3PackageFileInPlaceUsingManifestAndTargetRootAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + LauncherPaths paths = CreatePaths(testDirectory.Path); + ModificationVersion version = CreateS3Version(); + ContentIntegrityTarget packageTarget = CreateTarget( + "package", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"), + ContentSourceKind.ManagedS3); + LaunchContentIntegrityTargetContext packageContext = new(packageTarget, version, isCache: false); + ContentIntegrityReport report = CreateReport( + "package", + ContentSourceKind.ManagedS3, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.Repair, + "Data/file.gib"); + RemoteFileManifestEntry[] files = + { + new RemoteFileManifestEntry("Data/file.big", "0123456789ABCDEF0123456789ABCDEF", 10), + new RemoteFileManifestEntry("Data/readme.txt", "0123456789ABCDEF0123456789ABCDEF", 5), + }; + IS3ObjectManifestReader manifestReader = Substitute.For(); + manifestReader.ReadManifestAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult>(files)); + S3PackageFileRepairRequest? repairRequest = null; + PackageUpdateProgress packageProgress = new(10, 10, 100, "Data/file.big"); + IS3PackageUpdater s3PackageUpdater = Substitute.For(); + s3PackageUpdater.RepairFilesAsync( + Arg.Any(), + Arg.Any>(), + Arg.Any()) + .Returns(call => + { + repairRequest = call.ArgAt(0); + call.ArgAt>(1).Report(packageProgress); + return Task.CompletedTask; + }); + IContentIntegrityService integrityService = CreateIntegrityService(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + manifestReader: manifestReader, + s3PackageUpdater: s3PackageUpdater); + RecordingResolutionProgress progress = new(); + + // Act + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { packageContext }), + progress, + CancellationToken.None); + + // Assert + await manifestReader.Received(1).ReadManifestAsync( + Arg.Is(request => + request.Endpoint == "https://s3.example.test" && + request.BucketName == "mods" && + request.Prefix == "ShockWave/1.2"), + Arg.Any()); + repairRequest.Should().NotBeNull(); + repairRequest!.Files.Should().ContainSingle().Which.FileName.Should().Be("Data/file.big"); + repairRequest.InstalledFolderPath.Should().Be(packageTarget.RootDirectory); + repairRequest.HashCheckedExtensions.Should().BeEquivalentTo(".big", ".txt", ".gib"); + progress.Reports.Should().ContainSingle(report => + report.TargetId == "package" && + report.PackageProgress == packageProgress); + await integrityService.Received(1).CaptureSnapshotAsync(packageTarget, Arg.Any()); + await s3PackageUpdater.DidNotReceive().UpdateAsync( + Arg.Any(), + Arg.Any>(), + Arg.Any()); + } + + [Fact] + public async Task ResolveAsync_RepairsUntrackedManagedS3PackageUsingFullReplacementAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + LauncherPaths paths = CreatePaths(testDirectory.Path); + ModificationVersion version = CreateS3Version(); + ContentIntegrityTarget packageTarget = CreateTarget( + "package", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"), + ContentSourceKind.ManagedS3); + LaunchContentIntegrityTargetContext packageContext = new(packageTarget, version, isCache: false); + ContentIntegrityReport report = CreateReport( + "package", + ContentSourceKind.ManagedS3, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.Repair, + "."); + RemoteFileManifestEntry[] files = + { + new RemoteFileManifestEntry("Data/file.big", "0123456789ABCDEF0123456789ABCDEF", 10), + new RemoteFileManifestEntry("Data/readme.txt", "0123456789ABCDEF0123456789ABCDEF", 5), + }; + IS3ObjectManifestReader manifestReader = Substitute.For(); + manifestReader.ReadManifestAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult>(files)); + S3PackageUpdateRequest? updateRequest = null; + IS3PackageUpdater s3PackageUpdater = Substitute.For(); + s3PackageUpdater.UpdateAsync( + Arg.Any(), + Arg.Any>(), + Arg.Any()) + .Returns(call => + { + updateRequest = call.ArgAt(0); + return Task.CompletedTask; + }); + IContentIntegrityService integrityService = CreateIntegrityService(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + manifestReader: manifestReader, + s3PackageUpdater: s3PackageUpdater); + + // Act + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { packageContext }), + null, + CancellationToken.None); + + // Assert + updateRequest.Should().NotBeNull(); + updateRequest!.Files.Should().Equal(files); + updateRequest.InstalledFolderPath.Should().Be(packageTarget.RootDirectory); + updateRequest.LatestInstalledFolderPath.Should().Be(packageTarget.RootDirectory); + await s3PackageUpdater.DidNotReceive().RepairFilesAsync( + Arg.Any(), + Arg.Any>(), + Arg.Any()); + await integrityService.Received(1).CaptureSnapshotAsync(packageTarget, Arg.Any()); + } + + [Fact] + public async Task RegisterManualImportAsync_MarksVersionManualAndCapturesPackageAndCacheSnapshotsAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + LauncherPaths paths = CreatePaths(testDirectory.Path); + ModificationVersion version = CreateVersion(ContentSourceKind.UnknownLegacy); + ContentIntegrityTarget packageTarget = CreateTarget( + "package", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"), + ContentSourceKind.Manual); + ContentIntegrityTarget cacheTarget = CreateTarget( + "cache", + paths.GetModificationImagesDirectory("ShockWave"), + ContentSourceKind.Manual); + LaunchContentIntegrityTargetContext[] contexts = + [ + new LaunchContentIntegrityTargetContext(packageTarget, version, isCache: false), + new LaunchContentIntegrityTargetContext(cacheTarget, version, isCache: true), + ]; + ILaunchContentIntegrityTargetBuilder targetBuilder = Substitute.For(); + targetBuilder.BuildTargets(Arg.Any()).Returns(contexts); + IContentIntegrityService integrityService = CreateIntegrityService(); + ILauncherContentCatalogCommands catalogCommands = Substitute.For(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + targetBuilder, + catalogCommands: catalogCommands); + + // Act + await service.RegisterManualImportAsync( + new LaunchContentIntegrityVersionRequest(paths, version, new[] { version }, "cache"), + CancellationToken.None); + + // Assert + version.ContentSourceKind.Should().Be(ContentSourceKind.Manual); + catalogCommands.Received(1).SaveLauncherData(); + targetBuilder.Received().BuildTargets(Arg.Is(request => + request.Paths == paths && + request.ActiveVersions.Count == 1 && + request.ActiveVersions[0] == version && + request.CacheDisplayNameSuffix == "cache" && + request.AddonsFolderName == LaunchPreparationRequest.DefaultAddonsFolderName && + request.PatchesFolderName == LaunchPreparationRequest.DefaultPatchesFolderName)); + await integrityService.Received(1).CaptureSnapshotAsync(packageTarget, Arg.Any()); + await integrityService.Received(1).CaptureSnapshotAsync(cacheTarget, Arg.Any()); + } + + private static FileSystemLaunchContentIntegrityResolutionService CreateService( + IContentIntegrityService? integrityService = null, + ILaunchContentIntegrityTargetBuilder? targetBuilder = null, + IS3ObjectManifestReader? manifestReader = null, + IS3PackageUpdater? s3PackageUpdater = null, + ISingleFilePackageUpdater? singleFilePackageUpdater = null, + IRemoteAssetDownloader? assetDownloader = null, + ILauncherContentCatalogCommands? catalogCommands = null) + { + return new FileSystemLaunchContentIntegrityResolutionService( + integrityService ?? CreateIntegrityService(), + targetBuilder ?? Substitute.For(), + manifestReader ?? Substitute.For(), + s3PackageUpdater ?? Substitute.For(), + singleFilePackageUpdater ?? Substitute.For(), + assetDownloader ?? Substitute.For(), + catalogCommands ?? Substitute.For(), + NullLogger.Instance); + } + + private static IContentIntegrityService CreateIntegrityService() + { + IContentIntegrityService integrityService = Substitute.For(); + integrityService.CaptureSnapshotAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.CompletedTask); + integrityService.ApplyCleanupAsync( + Arg.Any(), + Arg.Any>(), + Arg.Any()) + .Returns(Task.CompletedTask); + integrityService.CaptureSnapshotIfMatchesExpectedFileSetAsync( + Arg.Any(), + Arg.Any>(), + Arg.Any()) + .Returns(Task.FromResult(false)); + return integrityService; + } + + private static ContentIntegrityReport CreateReport( + string targetId, + ContentSourceKind sourceKind, + IntegrityIssueKind issueKind, + IntegrityIssueAction action, + string relativePath = "Data/file.big") + { + return new ContentIntegrityReport(new[] + { + new ContentIntegrityIssue( + targetId, + "ShockWave 1.2", + sourceKind, + issueKind, + action, + relativePath), + }); + } + + private static ContentIntegrityTarget CreateTarget( + string id, + string rootDirectory, + ContentSourceKind sourceKind = ContentSourceKind.ManagedSingleFile, + IReadOnlySet? ignoredRelativePaths = null) + { + return new ContentIntegrityTarget( + id, + "ShockWave 1.2", + rootDirectory, + sourceKind, + ignoredRelativePaths ?? new HashSet(StringComparer.OrdinalIgnoreCase)); + } + + private static ModificationVersion CreateVersion(ContentSourceKind sourceKind) + { + return new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2", + SimpleDownloadLink = "https://www.dropbox.com/s/package/file.zip?dl=0", + UIImageSourceLink = "https://cdn.example.test/card.jpg", + ColorsInformation = new ColorsInfoString + { + GenLauncherBackgroundImageLink = "https://cdn.example.test/background.bmp", + }, + ContentSourceKind = sourceKind, + }; + } + + private static ModificationVersion CreateS3Version() + { + return new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2", + S3HostLink = "https://s3.example.test", + S3BucketName = "mods", + S3FolderName = "ShockWave/1.2", + ContentSourceKind = ContentSourceKind.ManagedS3, + }; + } + + private static LauncherPaths CreatePaths(string root) + { + string launcherDirectory = Path.Combine(root, "GenLauncherGO"); + + return new LauncherPaths( + Path.Combine(root, "Game"), + launcherDirectory, + Path.Combine(launcherDirectory, "Runtime"), + Path.Combine(launcherDirectory, "Runtime", "Cache"), + Path.Combine(launcherDirectory, "Runtime", "Cache", "Images"), + Path.Combine(launcherDirectory, "Mods"), + Path.Combine(launcherDirectory, "Logs"), + Path.Combine(launcherDirectory, "Runtime", "Temp"), + Path.Combine(launcherDirectory, "Runtime", "Deployment")); + } + + private sealed class RecordingResolutionProgress : IProgress + { + public List Reports { get; } = new(); + + public void Report(LaunchContentIntegrityResolutionProgress value) + { + Reports.Add(value); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilderTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilderTests.cs new file mode 100644 index 00000000..589fc66f --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilderTests.cs @@ -0,0 +1,78 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Launching.Services; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class FileSystemLaunchContentIntegrityTargetBuilderTests +{ + [Fact] + public void BuildTargetsUsesLauncherOwnedTempPathsAndIgnoresInactiveCacheFiles() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + Directory.CreateDirectory(paths.ModsDirectory); + Directory.CreateDirectory(paths.ImagesDirectory); + ModificationVersion activeVersion = CreateVersion("Rise", "1.0"); + ModificationVersion inactiveVersion = CreateVersion("Rise", "0.9"); + string cacheDirectory = paths.GetModificationImagesDirectory("Rise"); + Directory.CreateDirectory(cacheDirectory); + File.WriteAllText(Path.Combine(cacheDirectory, "1.0.png"), "active"); + File.WriteAllText(Path.Combine(cacheDirectory, "0.9.png"), "inactive"); + File.WriteAllText(Path.Combine(cacheDirectory, "0.9-background.jpg"), "inactive background"); + var builder = new FileSystemLaunchContentIntegrityTargetBuilder(); + + // Act + IReadOnlyList targets = builder.BuildTargets( + new LaunchContentIntegrityTargetRequest( + paths, + new[] { activeVersion }, + new[] { activeVersion, inactiveVersion }, + "cache")); + + // Assert + targets.Should().HaveCount(2); + LaunchContentIntegrityTargetContext packageTarget = targets.Single(target => !target.IsCache); + packageTarget.Target.RootDirectory.Should().Be(Path.Combine(paths.ModsDirectory, "Rise", "1.0")); + packageTarget.Target.SourceKind.Should().Be(ContentSourceKind.Manual); + LaunchContentIntegrityTargetContext cacheTarget = targets.Single(target => target.IsCache); + cacheTarget.Target.RootDirectory.Should().Be(cacheDirectory); + cacheTarget.Target.IgnoredRelativePaths.Should().BeEquivalentTo("0.9.png", "0.9-background.jpg"); + cacheTarget.Target.RootDirectory.Should().StartWith(paths.ImagesDirectory); + } + + private static ModificationVersion CreateVersion(string name, string version) + { + return new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = name, + Version = version, + ContentSourceKind = ContentSourceKind.Manual, + }; + } + + private static LauncherPaths CreatePaths(string root) + { + string gameDirectory = Path.Combine(root, "Game"); + string launcherDirectory = Path.Combine(gameDirectory, "GenLauncherGO"); + + return new LauncherPaths( + gameDirectory, + launcherDirectory, + Path.Combine(launcherDirectory, "Runtime"), + Path.Combine(launcherDirectory, "Runtime", "Cache"), + Path.Combine(launcherDirectory, "Runtime", "Cache", "Images"), + Path.Combine(launcherDirectory, "Mods"), + Path.Combine(launcherDirectory, "Logs"), + Path.Combine(launcherDirectory, "Runtime", "Temp"), + Path.Combine(launcherDirectory, "Runtime", "Deployment")); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryServiceTests.cs new file mode 100644 index 00000000..1601b158 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryServiceTests.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Launching.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class WindowsGameExecutableDiscoveryServiceTests +{ + [Fact] + public void GetAvailableGameClientsReturnsCommunityThenGeneralsOnlineWhenPresent() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + CreateGameFile(paths, "generalszh.exe"); + CreateGameFile(paths, "generalsonlinezh.exe"); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + // Act + IReadOnlyList clients = service.GetAvailableGameClients(SupportedGame.ZeroHour); + + // Assert + clients.Should().HaveCount(2); + clients[0].ExecutableName.Should().Be("generalszh.exe"); + clients[0].Kind.Should().Be(GameClientExecutableKind.Community); + clients[1].ExecutableName.Should().Be("generalsonlinezh.exe"); + clients[1].Kind.Should().Be(GameClientExecutableKind.GeneralsOnline); + } + + [Fact] + public void GetAvailableGameClientsUsesManagedGeneralsCommunityExecutable() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + CreateGameFile(paths, "generalsv.exe"); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + // Act + IReadOnlyList clients = service.GetAvailableGameClients(SupportedGame.Generals); + + // Assert + clients.Should().ContainSingle(); + clients[0].ExecutableName.Should().Be("generalsv.exe"); + clients[0].Kind.Should().Be(GameClientExecutableKind.Community); + } + + [Fact] + public void GetAvailableWorldBuildersReturnsVanillaThenCommunityWhenPresent() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + CreateGameFile(paths, "WorldBuilder.exe"); + CreateGameFile(paths, "worldbuilderzh.exe"); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + // Act + IReadOnlyList worldBuilders = + service.GetAvailableWorldBuilders(SupportedGame.ZeroHour); + + // Assert + worldBuilders.Should().HaveCount(2); + worldBuilders[0].ExecutableName.Should().Be("WorldBuilder.exe"); + worldBuilders[0].Kind.Should().Be(WorldBuilderExecutableKind.Vanilla); + worldBuilders[1].ExecutableName.Should().Be("worldbuilderzh.exe"); + worldBuilders[1].Kind.Should().Be(WorldBuilderExecutableKind.Community); + } + + [Fact] + public void IsExecutableAvailableChecksRelativeNamesInGameDirectory() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + CreateGameFile(paths, "generalszh.exe"); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + // Act + bool available = service.IsExecutableAvailable("generalszh.exe"); + bool missing = service.IsExecutableAvailable("missing.exe"); + + // Assert + available.Should().BeTrue(); + missing.Should().BeFalse(); + } + + [Fact] + public void IsExecutableAvailableSupportsRootedExecutablePaths() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + string executablePath = Path.Combine(directory.Path, "external.exe"); + File.WriteAllText(executablePath, string.Empty); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + // Act + bool available = service.IsExecutableAvailable(executablePath); + + // Assert + available.Should().BeTrue(); + } + + [Fact] + public void IsExecutableAvailableReturnsFalseForBlankExecutable() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + // Act + bool available = service.IsExecutableAvailable(" "); + + // Assert + available.Should().BeFalse(); + } + + private static WindowsGameExecutableDiscoveryService CreateService(LauncherPaths paths) + { + return new WindowsGameExecutableDiscoveryService( + paths, + new FixedGameProcessLauncher(), + NullLogger.Instance); + } + + private static void CreateGameFile(LauncherPaths paths, string fileName) + { + Directory.CreateDirectory(paths.GameDirectory); + File.WriteAllText(Path.Combine(paths.GameDirectory, fileName), string.Empty); + } + + private static LauncherPaths CreatePaths(string root) + { + string gameDirectory = Path.Combine(root, "Game"); + string launcherDirectory = Path.Combine(gameDirectory, "GenLauncherGO"); + + return new LauncherPaths( + gameDirectory, + launcherDirectory, + Path.Combine(launcherDirectory, "Runtime"), + Path.Combine(launcherDirectory, "Runtime", "Cache"), + Path.Combine(launcherDirectory, "Runtime", "Cache", "Images"), + Path.Combine(launcherDirectory, "Mods"), + Path.Combine(launcherDirectory, "Logs"), + Path.Combine(launcherDirectory, "Runtime", "Temp"), + Path.Combine(launcherDirectory, "Runtime", "Deployment")); + } + + private sealed class FixedGameProcessLauncher : IGameProcessLauncher + { + public string GetCommunityGameExecutableName(SupportedGame managedGame) + { + return managedGame == SupportedGame.ZeroHour + ? "generalszh.exe" + : "generalsv.exe"; + } + + public string GetCommunityWorldBuilderExecutableName(SupportedGame managedGame) + { + return managedGame == SupportedGame.ZeroHour + ? "worldbuilderzh.exe" + : "worldbuilderv.exe"; + } + + public Task StartAsync( + GameLaunchRequest request, + CancellationToken cancellationToken) + { + throw new NotSupportedException("Discovery tests do not launch processes."); + } + + public Task LaunchAsync( + GameLaunchRequest request, + CancellationToken cancellationToken) + { + return Task.FromResult(GameLaunchResult.Success("unused.exe", string.Empty, TimeSpan.Zero)); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameProcessLauncherTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameProcessLauncherTests.cs new file mode 100644 index 00000000..f3ce7c00 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameProcessLauncherTests.cs @@ -0,0 +1,155 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Launching.Services; +using GenLauncherGO.Infrastructure.Launching.Support; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class WindowsGameProcessLauncherTests +{ + [Fact] + public void CommunityExecutableSelectionUsesManagedGameVariant() + { + // Arrange + WindowsGameProcessLauncher launcher = CreateLauncher(new RecordingProcessFamilyLauncher()); + + // Act + string generalsExecutable = launcher.GetCommunityGameExecutableName(SupportedGame.Generals); + string zeroHourExecutable = launcher.GetCommunityGameExecutableName(SupportedGame.ZeroHour); + string generalsWorldBuilder = launcher.GetCommunityWorldBuilderExecutableName(SupportedGame.Generals); + string zeroHourWorldBuilder = launcher.GetCommunityWorldBuilderExecutableName(SupportedGame.ZeroHour); + + // Assert + generalsExecutable.Should().Be("generalsv.exe"); + zeroHourExecutable.Should().Be("generalszh.exe"); + generalsWorldBuilder.Should().Be("worldbuilderv.exe"); + zeroHourWorldBuilder.Should().Be("worldbuilderzh.exe"); + } + + [Fact] + public async Task LaunchAsyncBuildsCommunityGameExecutableAndArgumentsAsync() + { + // Arrange + var processLauncher = new RecordingProcessFamilyLauncher + { + RunningDuration = TimeSpan.FromSeconds(13), + }; + WindowsGameProcessLauncher launcher = CreateLauncher(processLauncher); + + // Act + GameLaunchResult result = await launcher.LaunchAsync( + GameLaunchRequest.ForGameClient(SupportedGame.ZeroHour, useGeneralsOnline: false, "-quickstart"), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + result.ExecutableName.Should().Be("generalszh.exe"); + result.Arguments.Should().Be("-quickstart"); + processLauncher.Calls.Should().ContainSingle().Which.Should().Be(("generalszh.exe", "-quickstart")); + } + + [Fact] + public async Task LaunchAsyncUsesGeneralsOnlineExecutableAndDropsArgumentsAsync() + { + // Arrange + var processLauncher = new RecordingProcessFamilyLauncher + { + RunningDuration = TimeSpan.FromSeconds(13), + }; + WindowsGameProcessLauncher launcher = CreateLauncher(processLauncher); + + // Act + GameLaunchResult result = await launcher.LaunchAsync( + GameLaunchRequest.ForGameClient(SupportedGame.ZeroHour, useGeneralsOnline: true, "-ignored"), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + result.ExecutableName.Should().Be("generalsonlinezh.exe"); + result.Arguments.Should().BeEmpty(); + processLauncher.Calls.Should().ContainSingle().Which.Should().Be(("generalsonlinezh.exe", string.Empty)); + } + + [Fact] + public async Task LaunchAsyncUsesExplicitWorldBuilderExecutableAndArgumentsAsync() + { + // Arrange + var processLauncher = new RecordingProcessFamilyLauncher(); + WindowsGameProcessLauncher launcher = CreateLauncher(processLauncher); + + // Act + GameLaunchResult result = await launcher.LaunchAsync( + GameLaunchRequest.ForWorldBuilder("worldbuilderzh.exe", "-wb"), + CancellationToken.None); + + // Assert + result.Succeeded.Should().BeTrue(); + result.ExecutableName.Should().Be("worldbuilderzh.exe"); + result.Arguments.Should().Be("-wb"); + processLauncher.Calls.Should().ContainSingle().Which.Should().Be(("worldbuilderzh.exe", "-wb")); + } + + private static WindowsGameProcessLauncher CreateLauncher(RecordingProcessFamilyLauncher processLauncher) + { + return new WindowsGameProcessLauncher( + processLauncher, + NullLogger.Instance); + } + + private sealed class RecordingProcessFamilyLauncher : IProcessFamilyLauncher + { + public TimeSpan RunningDuration { get; set; } = TimeSpan.FromSeconds(1); + + public List<(string ExecutableName, string Arguments)> Calls { get; } = new(); + + public Task StartAsync( + string executableName, + string arguments, + CancellationToken cancellationToken) + { + Calls.Add((executableName, arguments)); + return Task.FromResult( + new RecordingProcessFamilyLaunchOperation(executableName, RunningDuration)); + } + + public Task LaunchAndWaitForExitAsync( + string executableName, + string arguments, + CancellationToken cancellationToken) + { + return Task.FromResult(RunningDuration); + } + } + + private sealed class RecordingProcessFamilyLaunchOperation : IProcessFamilyLaunchOperation + { + public RecordingProcessFamilyLaunchOperation( + string executableName, + TimeSpan runningDuration) + { + ExecutableName = executableName; + Completion = Task.FromResult(runningDuration); + } + + public string ExecutableName { get; } + + public string CurrentExecutableName => ExecutableName; + + public event EventHandler? CurrentExecutableNameChanged + { + add { } + remove { } + } + + public Task Completion { get; } + + public void ForceClose() + { + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsProcessFamilyLauncherTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsProcessFamilyLauncherTests.cs new file mode 100644 index 00000000..2eedbc7f --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsProcessFamilyLauncherTests.cs @@ -0,0 +1,327 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Launching.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class WindowsProcessFamilyLauncherTests +{ + [Fact] + public void ConstructorThrowsWhenLoggerIsNull() + { + // Act + Action act = () => new WindowsProcessFamilyLauncher(null!); + + // Assert + act.Should().Throw().WithParameterName("logger"); + } + + [Fact] + public void LaunchAndWaitForExitAsyncThrowsForMissingExecutableName() + { + // Arrange + WindowsProcessFamilyLauncher launcher = new(NullLogger.Instance); + + // Act + Action act = () => launcher.LaunchAndWaitForExitAsync(" ", string.Empty, CancellationToken.None); + + // Assert + act.Should().Throw().WithParameterName("executableName"); + } + + [Fact] + public async Task LaunchAndWaitForExitAsyncReturnsDurationForShortLivedProcessAsync() + { + // Arrange + WindowsProcessFamilyLauncher launcher = new(NullLogger.Instance); + string executableName = Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe"; + + // Act + TimeSpan duration = await launcher.LaunchAndWaitForExitAsync( + executableName, + "/c exit 0", + CancellationToken.None); + + // Assert + duration.Should().BeGreaterThanOrEqualTo(TimeSpan.Zero); + } + + [Fact] + public void ProcessFamilyTrackerConstructorThrowsWhenDependenciesAreNull() + { + // Arrange + Func?> captureProcessSnapshot = () => Snapshot(); + Func isProcessRunning = _ => false; + Func getUtcNow = () => DateTime.UtcNow; + + // Act + Action nullLogger = () => new WindowsProcessFamilyLauncher.ProcessFamilyTracker( + 10, + null!, + captureProcessSnapshot, + isProcessRunning, + getUtcNow, + TimeSpan.FromSeconds(5)); + Action nullSnapshot = () => new WindowsProcessFamilyLauncher.ProcessFamilyTracker( + 10, + NullLogger.Instance, + null!, + isProcessRunning, + getUtcNow, + TimeSpan.FromSeconds(5)); + Action nullProcessRunning = () => new WindowsProcessFamilyLauncher.ProcessFamilyTracker( + 10, + NullLogger.Instance, + captureProcessSnapshot, + null!, + getUtcNow, + TimeSpan.FromSeconds(5)); + Action nullUtcNow = () => new WindowsProcessFamilyLauncher.ProcessFamilyTracker( + 10, + NullLogger.Instance, + captureProcessSnapshot, + isProcessRunning, + null!, + TimeSpan.FromSeconds(5)); + + // Assert + nullLogger.Should().Throw().WithParameterName("logger"); + nullSnapshot.Should().Throw().WithParameterName("captureProcessSnapshot"); + nullProcessRunning.Should().Throw().WithParameterName("isProcessRunning"); + nullUtcNow.Should().Throw().WithParameterName("getUtcNow"); + } + + [Fact] + public void ProcessFamilyTrackerTracksNestedDescendantsUntilGracePeriodExpires() + { + // Arrange + DateTime nowUtc = new(2026, 6, 21, 12, 0, 0, DateTimeKind.Utc); + Queue?> snapshots = new(new[] + { + Snapshot((10, 1), (20, 10), (30, 20)), + Snapshot((30, 20)), + Snapshot(), + Snapshot(), + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + rootProcessId: 10, + captureProcessSnapshot: () => snapshots.Dequeue(), + getUtcNow: () => nowUtc); + + // Act and Assert + tracker.IsRunning().Should().BeTrue(); + nowUtc = nowUtc.AddSeconds(1); + tracker.IsRunning().Should().BeTrue(); + nowUtc = nowUtc.AddSeconds(1); + tracker.IsRunning().Should().BeTrue(); + nowUtc = nowUtc.AddSeconds(6); + tracker.IsRunning().Should().BeFalse(); + tracker.RunningDuration.Should().Be(TimeSpan.FromSeconds(1)); + } + + [Fact] + public void ProcessFamilyTrackerAllowsHandoffChildFromRecentlyRetiredParent() + { + // Arrange + DateTime nowUtc = new(2026, 6, 21, 12, 0, 0, DateTimeKind.Utc); + Queue?> snapshots = new(new[] + { + Snapshot((10, 1), (20, 10)), + Snapshot(), + Snapshot((30, 20)), + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + rootProcessId: 10, + captureProcessSnapshot: () => snapshots.Dequeue(), + getUtcNow: () => nowUtc); + + // Act and Assert + tracker.IsRunning().Should().BeTrue(); + nowUtc = nowUtc.AddSeconds(1); + tracker.IsRunning().Should().BeTrue(); + nowUtc = nowUtc.AddSeconds(1); + tracker.IsRunning().Should().BeTrue(); + } + + [Fact] + public void ProcessFamilyTrackerRejectsHandoffChildAfterParentRetirementExpires() + { + // Arrange + DateTime nowUtc = new(2026, 6, 21, 12, 0, 0, DateTimeKind.Utc); + Queue?> snapshots = new(new[] + { + Snapshot((10, 1), (20, 10)), + Snapshot(), + Snapshot((30, 20)), + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + rootProcessId: 10, + captureProcessSnapshot: () => snapshots.Dequeue(), + getUtcNow: () => nowUtc); + + // Act and Assert + tracker.IsRunning().Should().BeTrue(); + nowUtc = nowUtc.AddSeconds(1); + tracker.IsRunning().Should().BeTrue(); + nowUtc = nowUtc.AddSeconds(6); + tracker.IsRunning().Should().BeFalse(); + } + + [Fact] + public void ProcessFamilyTrackerStopsImmediatelyWhenRootExitsWithoutChildren() + { + // Arrange + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + rootProcessId: 10, + captureProcessSnapshot: () => Snapshot()); + + // Act + bool result = tracker.IsRunning(); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void ProcessFamilyTrackerFallsBackToRootProcessWhenSnapshotsFail() + { + // Arrange + DateTime nowUtc = new(2026, 6, 21, 12, 0, 0, DateTimeKind.Utc); + Queue rootRunningStates = new(new[] { true, false }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + rootProcessId: 10, + captureProcessSnapshot: () => null, + isProcessRunning: _ => rootRunningStates.Dequeue(), + getUtcNow: () => nowUtc); + + // Act and Assert + tracker.IsRunning().Should().BeTrue(); + nowUtc = nowUtc.AddSeconds(3); + tracker.IsRunning().Should().BeFalse(); + tracker.RunningDuration.Should().Be(TimeSpan.Zero); + } + + [Fact] + public void ProcessFamilyTrackerForceCloseTargetsTrackedRunningFamily() + { + // Arrange + List forceClosedProcessIds = new(); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + rootProcessId: 10, + captureProcessSnapshot: () => Snapshot((10, 1), (20, 10), (30, 20), (40, 99)), + forceCloseProcess: forceClosedProcessIds.Add); + tracker.IsRunning().Should().BeTrue(); + + // Act + tracker.ForceClose(); + + // Assert + forceClosedProcessIds.Should().BeEquivalentTo(new[] { 10, 20, 30 }); + } + + [Fact] + public void ProcessFamilyTrackerUpdatesCurrentExecutableToDeepestRunningDescendant() + { + // Arrange + Queue?> snapshots = new(new[] + { + NamedSnapshot((10, 1, "generalsonlinezh.exe")), + NamedSnapshot((10, 1, "generalsonlinezh.exe"), (20, 10, "generalszh.exe")), + NamedSnapshot( + (10, 1, "generalsonlinezh.exe"), + (20, 10, "generalszh.exe"), + (30, 20, "game.dat")), + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + rootProcessId: 10, + rootExecutableName: "generalsonlinezh.exe", + captureProcessSnapshot: () => snapshots.Dequeue()); + + // Act and Assert + tracker.CurrentExecutableName.Should().Be("generalsonlinezh.exe"); + tracker.IsRunning().Should().BeTrue(); + tracker.CurrentExecutableName.Should().Be("generalsonlinezh.exe"); + tracker.IsRunning().Should().BeTrue(); + tracker.CurrentExecutableName.Should().Be("generalszh.exe"); + tracker.IsRunning().Should().BeTrue(); + tracker.CurrentExecutableName.Should().Be("game.dat"); + } + + [Fact] + public void ProcessFamilyTrackerStopsAfterChildHandoffExitsEvenWhenRootLauncherStillRuns() + { + // Arrange + DateTime nowUtc = new(2026, 6, 21, 12, 0, 0, DateTimeKind.Utc); + Queue?> snapshots = new(new[] + { + NamedSnapshot((10, 1, "generalsonlinezh.exe"), (20, 10, "generalszh.exe")), + NamedSnapshot((10, 1, "generalsonlinezh.exe")), + NamedSnapshot((10, 1, "generalsonlinezh.exe")), + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + rootProcessId: 10, + rootExecutableName: "generalsonlinezh.exe", + captureProcessSnapshot: () => snapshots.Dequeue(), + getUtcNow: () => nowUtc); + + // Act and Assert + tracker.IsRunning().Should().BeTrue(); + tracker.CurrentExecutableName.Should().Be("generalszh.exe"); + nowUtc = nowUtc.AddSeconds(1); + tracker.IsRunning().Should().BeTrue(); + tracker.CurrentExecutableName.Should().Be("generalszh.exe"); + nowUtc = nowUtc.AddSeconds(6); + tracker.IsRunning().Should().BeFalse(); + } + + private static WindowsProcessFamilyLauncher.ProcessFamilyTracker CreateTracker( + int rootProcessId, + Func?> captureProcessSnapshot, + string rootExecutableName = "", + Func? isProcessRunning = null, + Func? getUtcNow = null, + Action? forceCloseProcess = null) + { + return new WindowsProcessFamilyLauncher.ProcessFamilyTracker( + rootProcessId, + rootExecutableName, + NullLogger.Instance, + captureProcessSnapshot, + isProcessRunning ?? (_ => false), + getUtcNow ?? (() => new DateTime(2026, 6, 21, 12, 0, 0, DateTimeKind.Utc)), + TimeSpan.FromSeconds(5), + forceCloseProcess ?? (_ => { })); + } + + private static IReadOnlyList Snapshot( + params (int ProcessId, int ParentProcessId)[] entries) + { + List snapshot = new(); + foreach ((int processId, int parentProcessId) in entries) + { + snapshot.Add(new WindowsProcessFamilyLauncher.ProcessSnapshotEntry( + processId, + parentProcessId)); + } + + return snapshot; + } + + private static IReadOnlyList NamedSnapshot( + params (int ProcessId, int ParentProcessId, string ExecutableFileName)[] entries) + { + List snapshot = new(); + foreach ((int processId, int parentProcessId, string executableFileName) in entries) + { + snapshot.Add(new WindowsProcessFamilyLauncher.ProcessSnapshotEntry( + processId, + parentProcessId, + executableFileName)); + } + + return snapshot; + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Support/DeploymentPathResolverTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Support/DeploymentPathResolverTests.cs new file mode 100644 index 00000000..86ca18a4 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Support/DeploymentPathResolverTests.cs @@ -0,0 +1,156 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Launching.Support; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Support; + +public sealed class DeploymentPathResolverTests +{ + [Theory] + [InlineData(@"Data\INI\GameData.ini", "Data/INI/GameData.ini")] + [InlineData(@"Data//INI\\GameData.ini", "Data/INI/GameData.ini")] + public void NormalizeManifestPathNormalizesSeparators(string relativePath, string expectedPath) + { + // Act + string result = DeploymentPathResolver.NormalizeManifestPath(relativePath); + + // Assert + result.Should().Be(expectedPath); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void NormalizeManifestPathRejectsMissingPaths(string relativePath) + { + // Act + Action act = () => DeploymentPathResolver.NormalizeManifestPath(relativePath); + + // Assert + act.Should().Throw(); + } + + [Theory] + [InlineData(@"C:\Game\Data\GameData.ini")] + [InlineData("C:Game/Data/GameData.ini")] + [InlineData("../Data/GameData.ini")] + [InlineData("./Data/GameData.ini")] + public void NormalizeManifestPathRejectsUnsafePaths(string relativePath) + { + // Act + Action act = () => DeploymentPathResolver.NormalizeManifestPath(relativePath); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void ResolveGamePathReturnsPathInsideGameDirectory() + { + // Arrange + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + + // Act + string result = DeploymentPathResolver.ResolveGamePath(paths, @"Data\GameData.ini"); + + // Assert + result.Should().Be(Path.GetFullPath(Path.Combine(paths.GameDirectory, "Data", "GameData.ini"))); + } + + [Fact] + public void ResolveGamePathRejectsLauncherOwnedPaths() + { + // Arrange + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + + // Act + Action act = () => DeploymentPathResolver.ResolveGamePath(paths, "GenLauncherGO/Runtime/state.yaml"); + + // Assert + act.Should().Throw() + .WithMessage("*outside the game directory*"); + } + + [Fact] + public void ToRelativeManifestPathReturnsNormalizedChildPath() + { + // Arrange + using TestDirectory directory = new(); + string rootDirectory = Path.Combine(directory.Path, "Package"); + string path = Path.Combine(rootDirectory, "Data", "GameData.ini"); + + // Act + string result = DeploymentPathResolver.ToRelativeManifestPath(rootDirectory, path); + + // Assert + result.Should().Be("Data/GameData.ini"); + } + + [Fact] + public void ToRelativeManifestPathRejectsPathsOutsideRoot() + { + // Arrange + using TestDirectory directory = new(); + string rootDirectory = Path.Combine(directory.Path, "Package"); + string path = Path.Combine(directory.Path, "Other", "GameData.ini"); + + // Act + Action act = () => DeploymentPathResolver.ToRelativeManifestPath(rootDirectory, path); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void ResolveDeploymentStatePathReturnsPathInsideDeploymentDirectory() + { + // Arrange + using TestDirectory directory = new(); + string deploymentDirectory = Path.Combine(directory.Path, "Deployment"); + + // Act + string result = DeploymentPathResolver.ResolveDeploymentStatePath( + deploymentDirectory, + @"Records\manifest.yaml"); + + // Assert + result.Should().Be(Path.GetFullPath(Path.Combine(deploymentDirectory, "Records", "manifest.yaml"))); + } + + [Fact] + public void ResolveDeploymentStatePathRejectsPathsOutsideDeploymentDirectory() + { + // Arrange + using TestDirectory directory = new(); + string deploymentDirectory = Path.Combine(directory.Path, "Deployment"); + + // Act + Action act = () => DeploymentPathResolver.ResolveDeploymentStatePath( + deploymentDirectory, + "../manifest.yaml"); + + // Assert + act.Should().Throw(); + } + + private static LauncherPaths CreatePaths(TestDirectory directory) + { + string gameDirectory = Path.Combine(directory.Path, "Game"); + string launcherDirectory = Path.Combine(gameDirectory, "GenLauncherGO"); + + return new LauncherPaths( + gameDirectory, + launcherDirectory, + Path.Combine(launcherDirectory, "Runtime"), + Path.Combine(launcherDirectory, "Cache"), + Path.Combine(launcherDirectory, "Images"), + Path.Combine(launcherDirectory, "Mods"), + Path.Combine(launcherDirectory, "Logs"), + Path.Combine(launcherDirectory, "Temp"), + Path.Combine(launcherDirectory, "Deployment")); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Support/WindowsHardLinkCreatorTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Support/WindowsHardLinkCreatorTests.cs new file mode 100644 index 00000000..dfb04682 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Support/WindowsHardLinkCreatorTests.cs @@ -0,0 +1,44 @@ +using System.IO; +using GenLauncherGO.Infrastructure.Launching.Support; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Support; + +public sealed class WindowsHardLinkCreatorTests +{ + [Fact] + public void TryCreateHardLinkCreatesLinkToExistingFile() + { + // Arrange + using TestDirectory directory = new(); + string sourcePath = Path.Combine(directory.Path, "source.big"); + string targetPath = Path.Combine(directory.Path, "target.big"); + File.WriteAllText(sourcePath, "package"); + WindowsHardLinkCreator creator = new(); + + // Act + bool created = creator.TryCreateHardLink(targetPath, sourcePath); + + // Assert + created.Should().BeTrue(); + File.Exists(targetPath).Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("package"); + } + + [Fact] + public void TryCreateHardLinkReturnsFalseWhenSourceIsMissing() + { + // Arrange + using TestDirectory directory = new(); + string sourcePath = Path.Combine(directory.Path, "missing.big"); + string targetPath = Path.Combine(directory.Path, "target.big"); + WindowsHardLinkCreator creator = new(); + + // Act + bool created = creator.TryCreateHardLink(targetPath, sourcePath); + + // Assert + created.Should().BeFalse(); + File.Exists(targetPath).Should().BeFalse(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/LoggingServiceCollectionExtensionsTests.cs b/GenLauncherGO.Tests/Infrastructure/LoggingServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..f3e8e036 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/LoggingServiceCollectionExtensionsTests.cs @@ -0,0 +1,193 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using GenLauncherGO.Infrastructure.Logging; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Tests.Infrastructure; + +public sealed class LoggingServiceCollectionExtensionsTests +{ + [Fact] + public void AddGenLauncherGoLoggingThrowsForNullServiceCollection() + { + // Arrange + IServiceCollection? services = null; + + // Act + Action act = () => services!.AddGenLauncherGoLogging("logs"); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void AddGenLauncherGoLoggingThrowsForMissingLogDirectory() + { + // Arrange + var services = new ServiceCollection(); + + // Act + Action act = () => services.AddGenLauncherGoLogging(" "); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void AddGenLauncherGoLoggingRegistersLoggerFactoryAndCreatesLogDirectory() + { + // Arrange + string logDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + var services = new ServiceCollection(); + + try + { + // Act + IServiceCollection returnedServices = services.AddGenLauncherGoLogging(logDirectory); + using ServiceProvider provider = services.BuildServiceProvider(); + + // Assert + returnedServices.Should().BeSameAs(services); + Directory.Exists(logDirectory).Should().BeTrue(); + provider.GetRequiredService().Should().NotBeNull(); + } + finally + { + if (Directory.Exists(logDirectory)) + { + Directory.Delete(logDirectory, recursive: true); + } + } + } + + [Fact] + public void AddGenLauncherGoLoggingUsesReadableSessionLogFileName() + { + // Arrange + string logDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + try + { + // Act + for (int index = 0; index < 2; index++) + { + var services = new ServiceCollection(); + services.AddGenLauncherGoLogging(logDirectory); + using ServiceProvider provider = services.BuildServiceProvider(); + provider + .GetRequiredService>() + .LogInformation("Session {SessionIndex}", index); + } + + // Assert + string[] logFiles = Directory.GetFiles(logDirectory, "GenLauncherGO-*.log"); + logFiles.Should().HaveCount(2); + logFiles.Should().OnlyContain(file => + Regex.IsMatch( + Path.GetFileName(file), + @"^GenLauncherGO-\d{4}-\d{2}-\d{2}-\d{6}Z(-\d+)?\.log$")); + } + finally + { + if (Directory.Exists(logDirectory)) + { + Directory.Delete(logDirectory, recursive: true); + } + } + } + + [Fact] + public void AddGenLauncherGoLoggingPrunesOldSessionLogs() + { + // Arrange + string logDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + Directory.CreateDirectory(logDirectory); + + try + { + for (int index = 0; index < 20; index++) + { + string logFilePath = Path.Combine( + logDirectory, + $"GenLauncherGO-2026-01-{index + 1:00}-120000Z.log"); + File.WriteAllText(logFilePath, "old"); + File.SetLastWriteTimeUtc(logFilePath, DateTime.UtcNow.AddMinutes(-index - 1)); + } + + var services = new ServiceCollection(); + + // Act + services.AddGenLauncherGoLogging(logDirectory); + using (ServiceProvider provider = services.BuildServiceProvider()) + { + provider + .GetRequiredService>() + .LogInformation("Current session"); + } + + // Assert + Directory.GetFiles(logDirectory, "*.log").Should().HaveCountLessThanOrEqualTo(14); + File.Exists(Path.Combine(logDirectory, "GenLauncherGO-2026-01-20-120000Z.log")).Should().BeFalse(); + } + finally + { + if (Directory.Exists(logDirectory)) + { + Directory.Delete(logDirectory, recursive: true); + } + } + } + + [Fact] + public void AddGenLauncherGoLoggingRedactsLocalPathsAndSensitiveQueryValues() + { + // Arrange + string logDirectory = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + var services = new ServiceCollection(); + + try + { + const string SensitiveUrl = + "https://user:password@example.test/package?token=secret-value&X-Amz-Credential=aws-key" + + "&X-Amz-Signature=aws-signature&X-Amz-Security-Token=aws-token&name=safe"; + + // Act + services.AddGenLauncherGoLogging(logDirectory); + using (ServiceProvider provider = services.BuildServiceProvider()) + { + provider + .GetRequiredService>() + .LogError( + new InvalidOperationException(@"Failed under C:\Users\Alice\Secrets\file.txt"), + "Could not open {Path} from {Uri}.", + @"C:\Users\Alice\Secrets\file.txt", + SensitiveUrl); + } + + // Assert + string logText = File.ReadAllText(Directory.GetFiles(logDirectory, "GenLauncherGO-*.log").Single()); + logText.Should().Contain("[local path]"); + logText.Should().Contain("https://[redacted]@example.test"); + logText.Should().Contain("token=[redacted]"); + logText.Should().Contain("X-Amz-Credential=[redacted]"); + logText.Should().Contain("X-Amz-Signature=[redacted]"); + logText.Should().Contain("X-Amz-Security-Token=[redacted]"); + logText.Should().NotContain("Alice"); + logText.Should().NotContain("password"); + logText.Should().NotContain("secret-value"); + logText.Should().NotContain("aws-key"); + logText.Should().NotContain("aws-signature"); + logText.Should().NotContain("aws-token"); + } + finally + { + if (Directory.Exists(logDirectory)) + { + Directory.Delete(logDirectory, recursive: true); + } + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Composition/ModsInfrastructureServiceCollectionExtensionsTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Composition/ModsInfrastructureServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..cc46cf5b --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Composition/ModsInfrastructureServiceCollectionExtensionsTests.cs @@ -0,0 +1,74 @@ +using System.IO; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Composition; +using GenLauncherGO.Infrastructure.Mods.Contracts; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Infrastructure.Persistence.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Composition; + +public sealed class ModsInfrastructureServiceCollectionExtensionsTests +{ + [Fact] + public void AddGenLauncherGoModsInfrastructureRegistersContentServices() + { + // Arrange + using var directory = new TestDirectory(); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(CreatePaths(directory.Path)); + + // Act + services.AddGenLauncherGoModsInfrastructure(Path.Combine(directory.Path, "LauncherData.yaml")); + using ServiceProvider provider = services.BuildServiceProvider(); + + // Assert + provider.GetRequiredService>() + .Should().BeOfType>(); + provider.GetRequiredService() + .Should().BeOfType(); + provider.GetRequiredService() + .Should().BeOfType(); + provider.GetRequiredService() + .Should().BeOfType(); + provider.GetRequiredService() + .Should().BeOfType(); + provider.GetRequiredService() + .Should().BeOfType(); + provider.GetRequiredService() + .Should().BeOfType(); + provider.GetRequiredService() + .Should().BeOfType(); + provider.GetRequiredService() + .Should().BeOfType(); + provider.GetRequiredService() + .Should().BeOfType(); + LauncherContentCatalogService catalogService = provider.GetRequiredService(); + provider.GetRequiredService() + .Should().BeSameAs(catalogService); + provider.GetRequiredService() + .Should().BeSameAs(catalogService); + provider.GetRequiredService() + .Should().BeSameAs(catalogService); + provider.GetRequiredService() + .Should().BeSameAs(catalogService); + } + + private static LauncherPaths CreatePaths(string root) + { + return new LauncherPaths( + root, + Path.Combine(root, "GenLauncherGO"), + Path.Combine(root, "GenLauncherGO", "Runtime"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Cache"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Cache", "Images"), + Path.Combine(root, "GenLauncherGO", "Mods"), + Path.Combine(root, "GenLauncherGO", "Logs"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Temp"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Deployment")); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Models/RemoteLauncherCatalogTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Models/RemoteLauncherCatalogTests.cs new file mode 100644 index 00000000..1fe5a43b --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Models/RemoteLauncherCatalogTests.cs @@ -0,0 +1,39 @@ +using GenLauncherGO.Infrastructure.Mods.Models; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Models; + +public sealed class RemoteLauncherCatalogTests +{ + [Fact] + public void EmptyReturnsEmptyCollectionsAndVersion() + { + // Act + RemoteLauncherCatalog catalog = RemoteLauncherCatalog.Empty; + + // Assert + catalog.AdvertisingEntries.Should().BeEmpty(); + catalog.Modifications.Should().BeEmpty(); + catalog.OriginalGameAddonManifestUrls.Should().BeEmpty(); + catalog.OriginalGamePatchManifestUrls.Should().BeEmpty(); + catalog.LauncherVersion.Should().BeEmpty(); + } + + [Fact] + public void ConstructorNormalizesNullValues() + { + // Act + RemoteLauncherCatalog catalog = new( + null!, + null!, + null!, + null!, + null!); + + // Assert + catalog.AdvertisingEntries.Should().BeEmpty(); + catalog.Modifications.Should().BeEmpty(); + catalog.OriginalGameAddonManifestUrls.Should().BeEmpty(); + catalog.OriginalGamePatchManifestUrls.Should().BeEmpty(); + catalog.LauncherVersion.Should().BeEmpty(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemLocalLauncherContentServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemLocalLauncherContentServiceTests.cs new file mode 100644 index 00000000..9df70762 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemLocalLauncherContentServiceTests.cs @@ -0,0 +1,354 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class FileSystemLocalLauncherContentServiceTests +{ + [Fact] + public void FindInstalledVersionsReturnsInstalledModsPatchesAndAddons() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + CreateFile(Path.Combine(paths.ModsDirectory, "ShockWave", "1.2", "Data", "INI.big")); + CreateFile(Path.Combine(paths.ModsDirectory, "ShockWave", "Addons", "HD", "1.0", "HD.big")); + CreateFile(Path.Combine(paths.ModsDirectory, "ShockWave", "Patches", "Balance", "2.0", "Patch.big")); + Directory.CreateDirectory(Path.Combine(paths.ModsDirectory, "EmptyMod", "1.0")); + + // Act + IReadOnlyList versions = service.FindInstalledVersions(paths, Layout); + + // Assert + versions.Should().HaveCount(3); + versions.Should().ContainSingle(version => + version.ModificationType == LauncherContentType.Mod && + version.Name == "ShockWave" && + version.Version == "1.2" && + version.Installed); + versions.Should().ContainSingle(version => + version.ModificationType == LauncherContentType.Addon && + version.Name == "HD" && + version.Version == "1.0" && + version.DependenceName == "ShockWave" && + version.Installed); + versions.Should().ContainSingle(version => + version.ModificationType == LauncherContentType.Patch && + version.Name == "Balance" && + version.Version == "2.0" && + version.DependenceName == "ShockWave" && + version.Installed); + } + + [Fact] + public void VersionFolderContainsFilesReturnsFalseForMissingOrEmptyVersion() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + Directory.CreateDirectory(Path.Combine(paths.ModsDirectory, "ShockWave", "1.2")); + var emptyVersion = new LauncherContentVersionState + { + ModificationType = LauncherContentType.Mod, + Name = "ShockWave", + Version = "1.2" + }; + var missingVersion = new LauncherContentVersionState + { + ModificationType = LauncherContentType.Mod, + Name = "ShockWave", + Version = "2.0" + }; + + // Act + bool emptyExists = service.VersionFolderContainsFiles(paths, Layout, emptyVersion); + bool missingExists = service.VersionFolderContainsFiles(paths, Layout, missingVersion); + + // Assert + emptyExists.Should().BeFalse(); + missingExists.Should().BeFalse(); + } + + [Fact] + public void VersionFolderExistsReturnsTrueForEmptyVersionAndFalseForMissingVersion() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + Directory.CreateDirectory(Path.Combine(paths.ModsDirectory, "ShockWave", "Addons", "HD", "1.0")); + var emptyVersion = new LauncherContentVersionState + { + ModificationType = LauncherContentType.Addon, + Name = "HD", + Version = "1.0", + DependenceName = "ShockWave" + }; + var missingVersion = new LauncherContentVersionState + { + ModificationType = LauncherContentType.Addon, + Name = "HD", + Version = "2.0", + DependenceName = "ShockWave" + }; + + // Act + bool emptyExists = service.VersionFolderExists(paths, Layout, emptyVersion); + bool missingExists = service.VersionFolderExists(paths, Layout, missingVersion); + + // Assert + emptyExists.Should().BeTrue(); + missingExists.Should().BeFalse(); + } + + [Fact] + public void DeleteVersionDeletesVersionAndPrunesEmptyParents() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string versionDirectory = Path.Combine(paths.ModsDirectory, "ShockWave", "Addons", "HD", "1.0"); + CreateFile(Path.Combine(versionDirectory, "HD.big")); + var version = new LauncherContentVersionState + { + ModificationType = LauncherContentType.Addon, + Name = "HD", + Version = "1.0", + DependenceName = "ShockWave" + }; + + // Act + service.DeleteVersion(paths, Layout, version); + + // Assert + Directory.Exists(versionDirectory).Should().BeFalse(); + Directory.Exists(Path.Combine(paths.ModsDirectory, "ShockWave")).Should().BeFalse(); + Directory.Exists(paths.ModsDirectory).Should().BeTrue(); + } + + [Fact] + public void DeleteVersionDeletesPackageStagingFolderWhenInstalledFolderIsMissing() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string versionDirectory = Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"); + string packageStagingDirectory = paths.GetPackageTemporaryFolderPath(versionDirectory); + CreateFile(Path.Combine(packageStagingDirectory, "Data", "INI.big")); + var version = new LauncherContentVersionState + { + ModificationType = LauncherContentType.Mod, + Name = "ShockWave", + Version = "1.2" + }; + + // Act + service.DeleteVersion(paths, Layout, version); + + // Assert + Directory.Exists(packageStagingDirectory).Should().BeFalse(); + Directory.Exists(Path.Combine(paths.TempDirectory, "Packages", "ShockWave")).Should().BeFalse(); + Directory.Exists(versionDirectory).Should().BeFalse(); + } + + [Fact] + public void DeleteVersionDeletesPackageStagingFolderForChildContentWhenInstalledFolderIsMissing() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string versionDirectory = Path.Combine(paths.ModsDirectory, "ShockWave", "Addons", "HD", "1.0"); + string packageStagingDirectory = paths.GetPackageTemporaryFolderPath(versionDirectory); + CreateFile(Path.Combine(packageStagingDirectory, "HD.big")); + var version = new LauncherContentVersionState + { + ModificationType = LauncherContentType.Addon, + Name = "HD", + Version = "1.0", + DependenceName = "ShockWave" + }; + + // Act + service.DeleteVersion(paths, Layout, version); + + // Assert + Directory.Exists(packageStagingDirectory).Should().BeFalse(); + Directory.Exists(Path.Combine(paths.TempDirectory, "Packages", "ShockWave")).Should().BeFalse(); + Directory.Exists(versionDirectory).Should().BeFalse(); + } + + [Fact] + public void DeleteContentDeletesModRootAndPackageStagingRoot() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string contentDirectory = Path.Combine(paths.ModsDirectory, "ShockWave"); + string packageStagingDirectory = paths.GetPackageTemporaryFolderPath(contentDirectory); + CreateFile(Path.Combine(contentDirectory, "1.2", "Data", "INI.big")); + CreateFile(Path.Combine(contentDirectory, "Addons", "HD", "1.0", "HD.big")); + CreateFile(Path.Combine(packageStagingDirectory, "1.2", "Data", "INI.big")); + var version = new LauncherContentVersionState + { + ModificationType = LauncherContentType.Mod, + Name = "ShockWave", + Version = "1.2" + }; + + // Act + service.DeleteContent(paths, Layout, version); + + // Assert + Directory.Exists(contentDirectory).Should().BeFalse(); + Directory.Exists(packageStagingDirectory).Should().BeFalse(); + Directory.Exists(paths.ModsDirectory).Should().BeTrue(); + } + + [Fact] + public void DeleteContentDeletesChildContentRoot() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string contentDirectory = Path.Combine(paths.ModsDirectory, "ShockWave", "Addons", "HD"); + CreateFile(Path.Combine(contentDirectory, "1.0", "HD.big")); + CreateFile(Path.Combine(contentDirectory, "2.0", "HD.big")); + var version = new LauncherContentVersionState + { + ModificationType = LauncherContentType.Addon, + Name = "HD", + Version = "1.0", + DependenceName = "ShockWave" + }; + + // Act + service.DeleteContent(paths, Layout, version); + + // Assert + Directory.Exists(contentDirectory).Should().BeFalse(); + Directory.Exists(Path.Combine(paths.ModsDirectory, "ShockWave")).Should().BeFalse(); + } + + [Fact] + public void DeleteVersionRefusesPathOutsideModsRoot() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + var version = new LauncherContentVersionState + { + ModificationType = LauncherContentType.Mod, + Name = "..", + Version = "Outside" + }; + + // Act + Action act = () => service.DeleteVersion(paths, Layout, version); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void DeleteImagesIfUnusedDeletesVersionImagesWhenNoCardReferencesContentName() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + string cardImage = Path.Combine(imageDirectory, "1.2.png"); + string backgroundImage = Path.Combine(imageDirectory, "1.2-background.jpg"); + string otherImage = Path.Combine(imageDirectory, "readme.txt"); + CreateFile(cardImage); + CreateFile(backgroundImage); + CreateFile(otherImage); + var version = new LauncherContentVersionState + { + ModificationType = LauncherContentType.Mod, + Name = "ShockWave", + Version = "1.2" + }; + + // Act + service.DeleteImagesIfUnused(paths, version, new LauncherContentState()); + + // Assert + File.Exists(cardImage).Should().BeFalse(); + File.Exists(backgroundImage).Should().BeFalse(); + File.Exists(otherImage).Should().BeTrue(); + Directory.Exists(imageDirectory).Should().BeTrue(); + } + + [Fact] + public void DeleteImagesIfUnusedKeepsImagesWhenCardStillReferencesContentName() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string imagePath = Path.Combine(paths.GetModificationImagesDirectory("ShockWave"), "1.2.png"); + CreateFile(imagePath); + var version = new LauncherContentVersionState + { + ModificationType = LauncherContentType.Mod, + Name = "ShockWave", + Version = "1.2" + }; + var state = new LauncherContentState + { + Modifications = new List + { + new LauncherContentEntryState { Name = "ShockWave" } + } + }; + + // Act + service.DeleteImagesIfUnused(paths, version, state); + + // Assert + File.Exists(imagePath).Should().BeTrue(); + } + + private static LauncherContentLayout Layout { get; } = new LauncherContentLayout("Addons", "Patches"); + + private static FileSystemLocalLauncherContentService CreateService() + { + return new FileSystemLocalLauncherContentService( + NullLogger.Instance); + } + + private static LauncherPaths CreatePaths(string root) + { + return new LauncherPaths( + root, + Path.Combine(root, "GenLauncherGO"), + Path.Combine(root, "GenLauncherGO", "Runtime"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Cache"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Cache", "Images"), + Path.Combine(root, "GenLauncherGO", "Mods"), + Path.Combine(root, "GenLauncherGO", "Logs"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Temp"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Deployment")); + } + + private static void CreateFile(string filePath) + { + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + File.WriteAllText(filePath, String.Empty); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemManualModificationImporterTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemManualModificationImporterTests.cs new file mode 100644 index 00000000..a5d622fa --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemManualModificationImporterTests.cs @@ -0,0 +1,228 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using GenLauncherGO.Core.Archives; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class FileSystemManualModificationImporterTests +{ + [Fact] + public void ImportCopiesRegularFilesToDestination() + { + // Arrange + using var directory = new TestDirectory(); + string sourceDirectory = Path.Combine(directory.Path, "source"); + string destinationDirectory = Path.Combine(directory.Path, "destination"); + Directory.CreateDirectory(sourceDirectory); + string sourceFilePath = Path.Combine(sourceDirectory, "readme.txt"); + File.WriteAllText(sourceFilePath, "manual content"); + + FileSystemManualModificationImporter importer = CreateImporter(); + + // Act + importer.Import(new ManualModificationImportRequest( + new[] { sourceFilePath }, + destinationDirectory)); + + // Assert + File.ReadAllText(Path.Combine(destinationDirectory, "readme.txt")) + .Should().Be("manual content"); + File.Exists(sourceFilePath).Should().BeTrue(); + } + + [Fact] + public void ImportRenamesLooseBigFilesToGibFiles() + { + // Arrange + using var directory = new TestDirectory(); + string sourceDirectory = Path.Combine(directory.Path, "source"); + string destinationDirectory = Path.Combine(directory.Path, "destination"); + Directory.CreateDirectory(sourceDirectory); + string sourceFilePath = Path.Combine(sourceDirectory, "package.big"); + File.WriteAllText(sourceFilePath, "big content"); + + FileSystemManualModificationImporter importer = CreateImporter(); + + // Act + importer.Import(new ManualModificationImportRequest( + new[] { sourceFilePath }, + destinationDirectory)); + + // Assert + File.Exists(Path.Combine(destinationDirectory, "package.big")).Should().BeFalse(); + File.ReadAllText(Path.Combine(destinationDirectory, "package.gib")) + .Should().Be("big content"); + } + + [Fact] + public void ImportCopiesLooseGibFilesToDestination() + { + // Arrange + using var directory = new TestDirectory(); + string sourceDirectory = Path.Combine(directory.Path, "source"); + string destinationDirectory = Path.Combine(directory.Path, "destination"); + Directory.CreateDirectory(sourceDirectory); + string sourceFilePath = Path.Combine(sourceDirectory, "package.gib"); + File.WriteAllText(sourceFilePath, "gib content"); + RecordingLogger logger = new(); + + FileSystemManualModificationImporter importer = CreateImporter(logger: logger); + + // Act + importer.Import(new ManualModificationImportRequest( + new[] { sourceFilePath }, + destinationDirectory)); + + // Assert + File.ReadAllText(Path.Combine(destinationDirectory, "package.gib")) + .Should().Be("gib content"); + File.Exists(sourceFilePath).Should().BeTrue(); + logger.Entries.Should().Contain(entry => + entry.LogLevel == LogLevel.Information && + entry.Message.Contains("Imported 1 manual content file(s)", StringComparison.Ordinal)); + } + + [Fact] + public void ImportExtractsArchivesAndDeletesStagedArchive() + { + // Arrange + using var directory = new TestDirectory(); + string sourceDirectory = Path.Combine(directory.Path, "source"); + string destinationDirectory = Path.Combine(directory.Path, "destination"); + Directory.CreateDirectory(sourceDirectory); + string sourceFilePath = Path.Combine(sourceDirectory, "package.zip"); + File.WriteAllText(sourceFilePath, "archive content"); + + RecordingArchiveExtractor archiveExtractor = new(); + FileSystemManualModificationImporter importer = CreateImporter(archiveExtractor); + + // Act + importer.Import(new ManualModificationImportRequest( + new[] { sourceFilePath }, + destinationDirectory)); + + // Assert + archiveExtractor.ArchiveFilePath.Should().Be(Path.Combine(destinationDirectory, "package.zip")); + archiveExtractor.DestinationDirectory.Should().Be(destinationDirectory); + File.Exists(Path.Combine(destinationDirectory, "package.zip")).Should().BeFalse(); + File.ReadAllText(Path.Combine(destinationDirectory, "extracted.txt")) + .Should().Be("extracted content"); + File.Exists(sourceFilePath).Should().BeTrue(); + } + + [Fact] + public void ImportRejectsEmptySourceFileList() + { + // Arrange + FileSystemManualModificationImporter importer = CreateImporter(); + + // Act + Action act = () => importer.Import(new ManualModificationImportRequest( + Array.Empty(), + "destination")); + + // Assert + act.Should().Throw() + .WithMessage("*At least one source file is required*"); + } + + [Fact] + public void ImportLogsFailuresBeforeRethrowing() + { + // Arrange + using var directory = new TestDirectory(); + string missingSourceFilePath = Path.Combine(directory.Path, "missing.gib"); + string destinationDirectory = Path.Combine(directory.Path, "destination"); + RecordingLogger logger = new(); + FileSystemManualModificationImporter importer = CreateImporter(logger: logger); + + // Act + Action act = () => importer.Import(new ManualModificationImportRequest( + new[] { missingSourceFilePath }, + destinationDirectory)); + + // Assert + act.Should().Throw(); + logger.Entries.Should().Contain(entry => + entry.LogLevel == LogLevel.Error && + entry.Exception is FileNotFoundException && + entry.Message.Contains("Failed to import manual content", StringComparison.Ordinal)); + } + + private static FileSystemManualModificationImporter CreateImporter( + IArchiveExtractor? archiveExtractor = null, + ILogger? logger = null) + { + return new FileSystemManualModificationImporter( + archiveExtractor ?? Substitute.For(), + logger ?? NullLogger.Instance); + } + + private sealed class RecordingArchiveExtractor : IArchiveExtractor + { + public string? ArchiveFilePath { get; private set; } + + public string? DestinationDirectory { get; private set; } + + public ArchiveExtractionOptions? Options { get; private set; } + + public void ExtractToDirectory( + string archiveFilePath, + string destinationDirectory, + ArchiveExtractionOptions? options = null, + CancellationToken cancellationToken = default) + { + ArchiveFilePath = archiveFilePath; + DestinationDirectory = destinationDirectory; + Options = options; + File.WriteAllText(Path.Combine(destinationDirectory, "extracted.txt"), "extracted content"); + } + } + + private sealed class RecordingLogger : ILogger + { + public List Entries { get; } = new(); + + public IDisposable BeginScope(TState state) + where TState : notnull + { + return NullScope.Instance; + } + + public bool IsEnabled(LogLevel logLevel) + { + return true; + } + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + Entries.Add(new LogEntry(logLevel, formatter(state, exception), exception)); + } + } + + private sealed record LogEntry( + LogLevel LogLevel, + string Message, + Exception? Exception); + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + public void Dispose() + { + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemModificationImageFileServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemModificationImageFileServiceTests.cs new file mode 100644 index 00000000..7d44ef85 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemModificationImageFileServiceTests.cs @@ -0,0 +1,286 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class FileSystemModificationImageFileServiceTests +{ + [Fact] + public void FindExistingImageFilePathReturnsFirstMatchingImage() + { + // Arrange + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string imagePath = Path.Combine(imageDirectory, "1.2.jpg"); + File.WriteAllText(imagePath, "image"); + FileSystemModificationImageFileService service = CreateService(paths); + + // Act + string? existingImagePath = service.FindExistingImageFilePath("ShockWave", "1.2"); + + // Assert + existingImagePath.Should().Be(imagePath); + } + + [Fact] + public void FindExistingImageFilePathReturnsNullForMissingDirectory() + { + // Arrange + using TestDirectory directory = new(); + FileSystemModificationImageFileService service = CreateService(CreatePaths(directory.Path)); + + // Act + string? existingImagePath = service.FindExistingImageFilePath("Missing", "1.2"); + + // Assert + existingImagePath.Should().BeNull(); + } + + [Fact] + public void CountImageFilesReturnsZeroForMissingDirectory() + { + // Arrange + using TestDirectory directory = new(); + FileSystemModificationImageFileService service = CreateService(CreatePaths(directory.Path)); + + // Act + int count = service.CountImageFiles("Missing"); + + // Assert + count.Should().Be(0); + } + + [Fact] + public void CountImageFilesReturnsImageFileCount() + { + // Arrange + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + File.WriteAllText(Path.Combine(imageDirectory, "1.0.png"), "image"); + File.WriteAllText(Path.Combine(imageDirectory, "1.1.jpg"), "image"); + FileSystemModificationImageFileService service = CreateService(paths); + + // Act + int count = service.CountImageFiles("ShockWave"); + + // Assert + count.Should().Be(2); + } + + [Theory] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData(" ", false)] + public void ImageExistsReturnsFalseForMissingPathValues(string? imagePath, bool expected) + { + // Arrange + using TestDirectory directory = new(); + FileSystemModificationImageFileService service = CreateService(CreatePaths(directory.Path)); + + // Act + bool exists = service.ImageExists(imagePath); + + // Assert + exists.Should().Be(expected); + } + + [Fact] + public void ImageExistsReturnsTrueForExistingFile() + { + // Arrange + using TestDirectory directory = new(); + string imagePath = Path.Combine(directory.Path, "image.png"); + File.WriteAllText(imagePath, "image"); + FileSystemModificationImageFileService service = CreateService(CreatePaths(directory.Path)); + + // Act + bool exists = service.ImageExists(imagePath); + + // Assert + exists.Should().BeTrue(); + } + + [Fact] + public void TryDeleteImageRemovesExistingImage() + { + // Arrange + using TestDirectory directory = new(); + string imagePath = Path.Combine(directory.Path, "image.png"); + File.WriteAllText(imagePath, "image"); + FileSystemModificationImageFileService service = CreateService(CreatePaths(directory.Path)); + + // Act + bool deleted = service.TryDeleteImage(imagePath); + + // Assert + deleted.Should().BeTrue(); + File.Exists(imagePath).Should().BeFalse(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void TryDeleteImageReturnsTrueForMissingPathValues(string? imagePath) + { + // Arrange + using TestDirectory directory = new(); + FileSystemModificationImageFileService service = CreateService(CreatePaths(directory.Path)); + + // Act + bool deleted = service.TryDeleteImage(imagePath); + + // Assert + deleted.Should().BeTrue(); + } + + [Fact] + public void TryDeleteImageReturnsTrueWhenFileDoesNotExist() + { + // Arrange + using TestDirectory directory = new(); + FileSystemModificationImageFileService service = CreateService(CreatePaths(directory.Path)); + + // Act + bool deleted = service.TryDeleteImage(Path.Combine(directory.Path, "missing.png")); + + // Assert + deleted.Should().BeTrue(); + } + + [Fact] + public async Task ReplaceImageAsyncDeletesStaleExtensionsAndCopiesSelectedImageAsync() + { + // Arrange + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string staleImagePath = Path.Combine(imageDirectory, "1.2.jpg"); + File.WriteAllText(staleImagePath, "old"); + string sourceImagePath = Path.Combine(directory.Path, "selected.png"); + File.WriteAllText(sourceImagePath, "new"); + FileSystemModificationImageFileService service = CreateService(paths); + + // Act + string destinationPath = await service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", sourceImagePath), + CancellationToken.None); + + // Assert + destinationPath.Should().Be(Path.Combine(imageDirectory, "1.2.png")); + File.Exists(staleImagePath).Should().BeFalse(); + File.ReadAllText(destinationPath).Should().Be("new"); + } + + [Fact] + public async Task ReplaceImageAsyncNoOpsWhenSourceAlreadyIsDestinationAsync() + { + // Arrange + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string existingImagePath = Path.Combine(imageDirectory, "1.2.png"); + File.WriteAllText(existingImagePath, "same"); + FileSystemModificationImageFileService service = CreateService(paths); + + // Act + string destinationPath = await service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", existingImagePath), + CancellationToken.None); + + // Assert + destinationPath.Should().Be(existingImagePath); + File.ReadAllText(existingImagePath).Should().Be("same"); + } + + [Fact] + public async Task ReplaceImageAsyncThrowsForSourceWithoutExtensionAsync() + { + // Arrange + using TestDirectory directory = new(); + string sourceImagePath = Path.Combine(directory.Path, "selected"); + File.WriteAllText(sourceImagePath, "new"); + FileSystemModificationImageFileService service = CreateService(CreatePaths(directory.Path)); + + // Act + Func act = () => service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", sourceImagePath), + CancellationToken.None); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ReplaceImageAsyncThrowsIOExceptionWhenSourceCannotBeCopiedAsync() + { + // Arrange + using TestDirectory directory = new(); + string sourceImagePath = Path.Combine(directory.Path, "missing.png"); + FileSystemModificationImageFileService service = CreateService(CreatePaths(directory.Path)); + + // Act + Func act = () => service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", sourceImagePath), + CancellationToken.None); + + // Assert + (await act.Should().ThrowAsync() + .WithMessage("Could not replace cached image '1.2' for modification 'ShockWave'.")) + .Which.InnerException.Should().BeOfType(); + } + + [Fact] + public async Task ReplaceImageAsyncHonorsPreCanceledTokenAsync() + { + // Arrange + using TestDirectory directory = new(); + string sourceImagePath = Path.Combine(directory.Path, "selected.png"); + File.WriteAllText(sourceImagePath, "new"); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + FileSystemModificationImageFileService service = CreateService(CreatePaths(directory.Path)); + + // Act + Func act = () => service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", sourceImagePath), + cancellation.Token); + + // Assert + await act.Should().ThrowAsync(); + } + + private static FileSystemModificationImageFileService CreateService(LauncherPaths paths) + { + return new FileSystemModificationImageFileService( + paths, + NullLogger.Instance); + } + + private static LauncherPaths CreatePaths(string root) + { + return new LauncherPaths( + root, + Path.Combine(root, "GenLauncherGO"), + Path.Combine(root, "GenLauncherGO", "Runtime"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Cache"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Cache", "Images"), + Path.Combine(root, "GenLauncherGO", "Mods"), + Path.Combine(root, "GenLauncherGO", "Logs"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Temp"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Deployment")); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherCatalogImageCacheTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherCatalogImageCacheTests.cs new file mode 100644 index 00000000..503d9788 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherCatalogImageCacheTests.cs @@ -0,0 +1,294 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Remote; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class LauncherCatalogImageCacheTests +{ + [Fact] + public async Task CacheModificationImagesAsyncDownloadsCardAndBackgroundImagesToExpectedPathsAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + IRemoteAssetDownloader assetDownloader = Substitute.For(); + var cache = new LauncherCatalogImageCache( + assetDownloader, + NullLogger.Instance); + var cardUri = new Uri("https://cdn.example.test/card.jpeg"); + var backgroundUri = new Uri("https://cdn.example.test/background.unsupported"); + var modification = new RemoteContentManifest + { + Name = "ShockWave", + Version = "1.2", + ImageSourceLink = cardUri.ToString(), + ColorsInformation = new ColorsInfoString + { + GenLauncherBackgroundImageLink = backgroundUri.ToString() + } + }; + + // Act + await cache.CacheModificationImagesAsync(modification, paths, CancellationToken.None); + + // Assert + await assetDownloader.Received(1).DownloadIfMissingAsync( + cardUri, + paths.GetModificationImageFilePath("ShockWave", "1.2.jpeg"), + Arg.Any()); + await assetDownloader.Received(1).DownloadIfMissingAsync( + backgroundUri, + paths.GetModificationImageFilePath("ShockWave", "1.2-background.png"), + Arg.Any()); + } + + [Fact] + public async Task CacheModificationImagesAsyncSkipsEmptyImageLinksAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + IRemoteAssetDownloader assetDownloader = Substitute.For(); + var cache = new LauncherCatalogImageCache( + assetDownloader, + NullLogger.Instance); + var modification = new RemoteContentManifest + { + Name = "ShockWave", + Version = "1.2", + ImageSourceLink = string.Empty, + ColorsInformation = new ColorsInfoString + { + GenLauncherBackgroundImageLink = string.Empty + } + }; + + // Act + await cache.CacheModificationImagesAsync(modification, paths, CancellationToken.None); + + // Assert + await assetDownloader.DidNotReceiveWithAnyArgs().DownloadIfMissingAsync( + null!, + null!, + CancellationToken.None); + } + + [Fact] + public async Task CacheModificationImagesAsyncContinuesWhenOneImageDownloadFailsAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + IRemoteAssetDownloader assetDownloader = Substitute.For(); + var cache = new LauncherCatalogImageCache( + assetDownloader, + NullLogger.Instance); + var cardUri = new Uri("https://cdn.example.test/card.png"); + var backgroundUri = new Uri("https://cdn.example.test/background.jpg"); + var modification = new RemoteContentManifest + { + Name = "ShockWave", + Version = "1.2", + ImageSourceLink = cardUri.ToString(), + ColorsInformation = new ColorsInfoString + { + GenLauncherBackgroundImageLink = backgroundUri.ToString() + } + }; + assetDownloader.DownloadIfMissingAsync( + cardUri, + paths.GetModificationImageFilePath("ShockWave", "1.2.png"), + Arg.Any()) + .Returns(Task.FromException(new IOException("Download failed."))); + + // Act + await cache.CacheModificationImagesAsync(modification, paths, CancellationToken.None); + + // Assert + await assetDownloader.Received(1).DownloadIfMissingAsync( + cardUri, + paths.GetModificationImageFilePath("ShockWave", "1.2.png"), + Arg.Any()); + await assetDownloader.Received(1).DownloadIfMissingAsync( + backgroundUri, + paths.GetModificationImageFilePath("ShockWave", "1.2-background.jpg"), + Arg.Any()); + } + + [Fact] + public async Task CacheModificationImagesAsyncRethrowsCancellationAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + IRemoteAssetDownloader assetDownloader = Substitute.For(); + var cache = new LauncherCatalogImageCache( + assetDownloader, + NullLogger.Instance); + var cancellationTokenSource = new CancellationTokenSource(); + var imageUri = new Uri("https://cdn.example.test/card.png"); + var modification = new RemoteContentManifest + { + Name = "ShockWave", + Version = "1.2", + ImageSourceLink = imageUri.ToString() + }; + cancellationTokenSource.Cancel(); + assetDownloader.DownloadIfMissingAsync( + imageUri, + paths.GetModificationImageFilePath("ShockWave", "1.2.png"), + cancellationTokenSource.Token) + .Returns(Task.FromCanceled(cancellationTokenSource.Token)); + + // Act + Func act = () => cache.CacheModificationImagesAsync( + modification, + paths, + cancellationTokenSource.Token); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task CacheAdvertisingImagesAsyncDeletesStaleImagesWhenImageCountChangesAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + Directory.CreateDirectory(paths.GetModificationImagesDirectory("Featured Mod")); + string staleImagePath = paths.GetModificationImageFilePath("Featured Mod", "old.png"); + await File.WriteAllTextAsync(staleImagePath, "stale"); + IRemoteAssetDownloader assetDownloader = Substitute.For(); + var cache = new LauncherCatalogImageCache( + assetDownloader, + NullLogger.Instance); + var advertisingData = new RemoteAdvertisingReference( + "Featured Mod", + "https://example.test/featured.yaml", + new List + { + "https://cdn.example.test/0.jpg", + "https://cdn.example.test/1.jpg" + }); + + // Act + await cache.CacheAdvertisingImagesAsync(advertisingData, paths, CancellationToken.None); + + // Assert + File.Exists(staleImagePath).Should().BeFalse(); + await assetDownloader.Received(1).DownloadIfMissingAsync( + new Uri("https://cdn.example.test/0.jpg"), + paths.GetModificationImageFilePath("Featured Mod", "0.jpg"), + Arg.Any()); + await assetDownloader.Received(1).DownloadIfMissingAsync( + new Uri("https://cdn.example.test/1.jpg"), + paths.GetModificationImageFilePath("Featured Mod", "1.jpg"), + Arg.Any()); + } + + [Fact] + public async Task CacheAdvertisingImagesAsyncContinuesWhenStaleImageCannotBeDeletedAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + Directory.CreateDirectory(paths.GetModificationImagesDirectory("Featured Mod")); + string staleImagePath = paths.GetModificationImageFilePath("Featured Mod", "old.png"); + await File.WriteAllTextAsync(staleImagePath, "stale"); + await using FileStream lockedImage = File.Open( + staleImagePath, + FileMode.Open, + FileAccess.Read, + FileShare.None); + IRemoteAssetDownloader assetDownloader = Substitute.For(); + var cache = new LauncherCatalogImageCache( + assetDownloader, + NullLogger.Instance); + var advertisingData = new RemoteAdvertisingReference( + "Featured Mod", + "https://example.test/featured.yaml", + new List + { + "https://cdn.example.test/0.jpg", + "https://cdn.example.test/1.jpg" + }); + + // Act + Func act = () => cache.CacheAdvertisingImagesAsync( + advertisingData, + paths, + CancellationToken.None); + + // Assert + await act.Should().NotThrowAsync(); + await assetDownloader.Received(1).DownloadIfMissingAsync( + new Uri("https://cdn.example.test/0.jpg"), + paths.GetModificationImageFilePath("Featured Mod", "0.jpg"), + Arg.Any()); + } + + [Fact] + public async Task CacheAdvertisingImagesAsyncKeepsExistingImagesWhenImageCountMatchesAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + Directory.CreateDirectory(paths.GetModificationImagesDirectory("Featured Mod")); + string firstExistingImagePath = paths.GetModificationImageFilePath("Featured Mod", "0.png"); + string secondExistingImagePath = paths.GetModificationImageFilePath("Featured Mod", "1.png"); + await File.WriteAllTextAsync(firstExistingImagePath, "existing"); + await File.WriteAllTextAsync(secondExistingImagePath, "existing"); + IRemoteAssetDownloader assetDownloader = Substitute.For(); + var cache = new LauncherCatalogImageCache( + assetDownloader, + NullLogger.Instance); + var advertisingData = new RemoteAdvertisingReference( + "Featured Mod", + "https://example.test/featured.yaml", + new List + { + "https://cdn.example.test/0.png", + "https://cdn.example.test/1.png" + }); + + // Act + await cache.CacheAdvertisingImagesAsync(advertisingData, paths, CancellationToken.None); + + // Assert + File.Exists(firstExistingImagePath).Should().BeTrue(); + File.Exists(secondExistingImagePath).Should().BeTrue(); + await assetDownloader.Received(1).DownloadIfMissingAsync( + new Uri("https://cdn.example.test/0.png"), + paths.GetModificationImageFilePath("Featured Mod", "0.png"), + Arg.Any()); + await assetDownloader.Received(1).DownloadIfMissingAsync( + new Uri("https://cdn.example.test/1.png"), + paths.GetModificationImageFilePath("Featured Mod", "1.png"), + Arg.Any()); + } + + private static LauncherPaths CreatePaths(string root) + { + return new LauncherPaths( + root, + Path.Combine(root, "GenLauncherGO"), + Path.Combine(root, "GenLauncherGO", "Runtime"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Cache"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Cache", "Images"), + Path.Combine(root, "GenLauncherGO", "Mods"), + Path.Combine(root, "GenLauncherGO", "Logs"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Temp"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Deployment")); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentCatalogServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentCatalogServiceTests.cs new file mode 100644 index 00000000..ccb03316 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentCatalogServiceTests.cs @@ -0,0 +1,945 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Remote; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class LauncherContentCatalogServiceTests +{ + [Fact] + public async Task InitDataAsyncWithDisconnectedCatalogLoadsOnlyLocalStateAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + ILauncherContentStateStore stateStore = Substitute.For(); + ILocalLauncherContentService localContentService = Substitute.For(); + IRemoteYamlDocumentReader yamlReader = Substitute.For(); + IRemoteAssetDownloader assetDownloader = Substitute.For(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + yamlReader, + assetDownloader); + stateStore.Load().Returns(new LauncherContentState + { + Modifications = new List + { + new LauncherContentEntryState + { + Name = "ShockWave", + ModificationVersions = new List + { + new LauncherContentVersionState + { + Name = "ShockWave", + Version = "1.0", + Installed = true + } + } + } + } + }); + localContentService.FindInstalledVersions(paths, Layout) + .Returns(new List + { + new LauncherContentVersionState + { + ModificationType = LauncherContentType.Mod, + Name = "ShockWave", + Version = "1.0", + Installed = true + } + }); + + // Act + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(false, null, paths, Layout), + CancellationToken.None); + await service.ReadPatchesAndAddonsForModAsync(new ModificationReposVersion("ShockWave"), CancellationToken.None); + + // Assert + service.GetAllModificationsNames().Should().Equal("ShockWave"); + service.ReposModsNames.Should().BeNull(); + await yamlReader.DidNotReceiveWithAnyArgs() + .ReadYamlAsync(default!, default); + } + + [Fact] + public async Task InitDataAsyncThrowsWhenConnectedCatalogHasNoRemoteManifestAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherContentCatalogService service = CreateService( + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For()); + + // Act + Func act = () => service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(true, null, CreatePaths(directory.Path), Layout), + CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("A remote manifest URI is required when initializing a connected catalog.*"); + } + + [Fact] + public async Task InitDataAsyncReadsRemoteCatalogForInstalledModsAndDownloadsImagesAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var modUri = new Uri("https://example.test/shockwave.yaml"); + var cardImageUri = new Uri("https://cdn.example.test/shockwave.jpg"); + var backgroundImageUri = new Uri("https://cdn.example.test/shockwave-background.png"); + ILauncherContentStateStore stateStore = Substitute.For(); + ILocalLauncherContentService localContentService = Substitute.For(); + IRemoteYamlDocumentReader yamlReader = Substitute.For(); + IRemoteAssetDownloader assetDownloader = Substitute.For(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + yamlReader, + assetDownloader); + + stateStore.Load().Returns(new LauncherContentState + { + Modifications = new List + { + new LauncherContentEntryState + { + Name = "ShockWave", + Installed = true, + ModificationVersions = new List + { + new LauncherContentVersionState + { + Name = "ShockWave", + Version = "1.0", + Installed = true + } + } + } + } + }); + localContentService.FindInstalledVersions(paths, Layout) + .Returns(new List + { + new LauncherContentVersionState + { + ModificationType = LauncherContentType.Mod, + Name = "ShockWave", + Version = "1.0", + Installed = true + } + }); + yamlReader.ReadYamlAsync(manifestUri, Arg.Any()) + .Returns(Task.FromResult(new ReposModsData + { + modDatas = new List + { + new ModAddonsAndPatches + { + ModName = "ShockWave", + ModLink = modUri.ToString() + } + } + })); + yamlReader.ReadYamlAsync(modUri, Arg.Any()) + .Returns(Task.FromResult(new ModificationReposVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2", + UIImageSourceLink = cardImageUri.ToString(), + ColorsInformation = new ColorsInfoString + { + GenLauncherBackgroundImageLink = backgroundImageUri.ToString() + } + })); + + // Act + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(true, manifestUri, paths, Layout), + CancellationToken.None); + + // Assert + service.ReposModsNames.Should().Equal("ShockWave"); + GameModification mod = service.GetMods().Should().ContainSingle(item => item.Name == "ShockWave").Subject; + mod.ModificationVersions.Should().Contain(version => version.Version == "1.2"); + await assetDownloader.Received(1).DownloadIfMissingAsync( + cardImageUri, + paths.GetModificationImageFilePath("ShockWave", "1.2.jpg"), + Arg.Any()); + await assetDownloader.Received(1).DownloadIfMissingAsync( + backgroundImageUri, + paths.GetModificationImageFilePath("ShockWave", "1.2-background.png"), + Arg.Any()); + } + + [Fact] + public async Task InitDataAsyncLoadsSelectedModPatchesAndAddonsAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var modUri = new Uri("https://example.test/shockwave.yaml"); + var patchUri = new Uri("https://example.test/patch.yaml"); + var addonUri = new Uri("https://example.test/addon.yaml"); + ILauncherContentStateStore stateStore = Substitute.For(); + ILocalLauncherContentService localContentService = Substitute.For(); + IRemoteYamlDocumentReader yamlReader = Substitute.For(); + IRemoteAssetDownloader assetDownloader = Substitute.For(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + yamlReader, + assetDownloader); + + stateStore.Load().Returns(new LauncherContentState + { + Modifications = new List + { + new LauncherContentEntryState + { + Name = "ShockWave", + IsSelected = true, + ModificationVersions = new List + { + new LauncherContentVersionState + { + Name = "ShockWave", + Version = "1.0", + Installed = true, + IsSelected = true + } + } + } + } + }); + localContentService.FindInstalledVersions(paths, Layout) + .Returns(new List + { + new LauncherContentVersionState + { + ModificationType = LauncherContentType.Mod, + Name = "ShockWave", + Version = "1.0", + Installed = true + } + }); + yamlReader.ReadYamlAsync(manifestUri, Arg.Any()) + .Returns(Task.FromResult(new ReposModsData + { + modDatas = new List + { + new ModAddonsAndPatches + { + ModName = "ShockWave", + ModLink = modUri.ToString(), + ModPatches = new List { patchUri.ToString() }, + ModAddons = new List { addonUri.ToString() } + } + } + })); + yamlReader.ReadYamlAsync(modUri, Arg.Any()) + .Returns(Task.FromResult(CreateRemoteVersion("ShockWave", "1.2", ModificationType.Mod))); + yamlReader.ReadYamlAsync(patchUri, Arg.Any()) + .Returns(Task.FromResult(CreateRemoteVersion("Balance", "2.0", ModificationType.Patch, "ShockWave"))); + yamlReader.ReadYamlAsync(addonUri, Arg.Any()) + .Returns(Task.FromResult(CreateRemoteVersion("HD", "1.0", ModificationType.Addon, "ShockWave"))); + + // Act + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(true, manifestUri, paths, Layout), + CancellationToken.None); + await service.ReadPatchesAndAddonsForModAsync(new ModificationReposVersion("ShockWave"), CancellationToken.None); + + // Assert + service.GetPatchVersionsForModList("ShockWave").Should() + .ContainSingle(version => version.Name == "Balance" && version.Version == "2.0"); + service.GetAddonVersionsForModList("ShockWave").Should() + .ContainSingle(version => version.Name == "HD" && version.Version == "1.0"); + await yamlReader.Received(1).ReadYamlAsync( + patchUri, + Arg.Any()); + await yamlReader.Received(1).ReadYamlAsync( + addonUri, + Arg.Any()); + } + + [Fact] + public async Task ReadOriginalGameAddonsAndPatchesAsyncLoadsChildContentOnceAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var patchUri = new Uri("https://example.test/original-patch.yaml"); + var addonUri = new Uri("https://example.test/original-addon.yaml"); + ILauncherContentStateStore stateStore = Substitute.For(); + ILocalLauncherContentService localContentService = Substitute.For(); + IRemoteYamlDocumentReader yamlReader = Substitute.For(); + IRemoteAssetDownloader assetDownloader = Substitute.For(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + yamlReader, + assetDownloader); + + stateStore.Load().Returns(new LauncherContentState()); + localContentService.FindInstalledVersions(paths, Layout) + .Returns(Array.Empty()); + yamlReader.ReadYamlAsync(manifestUri, Arg.Any()) + .Returns(Task.FromResult(new ReposModsData + { + originalGamePatches = new List { patchUri.ToString() }, + originalGameAddons = new List { addonUri.ToString() } + })); + yamlReader.ReadYamlAsync(patchUri, Arg.Any()) + .Returns(Task.FromResult(CreateRemoteVersion("GenPatcher", "1.0", ModificationType.Patch))); + yamlReader.ReadYamlAsync(addonUri, Arg.Any()) + .Returns(Task.FromResult(CreateRemoteVersion("ControlBar", "1.0", ModificationType.Addon))); + + // Act + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(true, manifestUri, paths, Layout), + CancellationToken.None); + await service.ReadOriginalGameAddonsAndPatchesAsync(CancellationToken.None); + await service.ReadOriginalGameAddonsAndPatchesAsync(CancellationToken.None); + + // Assert + service.GetPatchVersionsForModList("Original Game").Should() + .ContainSingle(version => version.Name == "GenPatcher"); + service.GetAddonVersionsForModList("Original Game").Should() + .ContainSingle(version => version.Name == "ControlBar"); + await yamlReader.Received(1).ReadYamlAsync( + patchUri, + Arg.Any()); + await yamlReader.Received(1).ReadYamlAsync( + addonUri, + Arg.Any()); + } + + [Fact] + public async Task ReadOriginalGameAddonsAndPatchesAsyncReturnsWhenCatalogIsDisconnectedAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + ILauncherContentStateStore stateStore = Substitute.For(); + ILocalLauncherContentService localContentService = Substitute.For(); + IRemoteYamlDocumentReader yamlReader = Substitute.For(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + yamlReader, + Substitute.For()); + + stateStore.Load().Returns(new LauncherContentState()); + localContentService.FindInstalledVersions(paths, Layout) + .Returns(Array.Empty()); + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(false, null, paths, Layout), + CancellationToken.None); + + // Act + await service.ReadOriginalGameAddonsAndPatchesAsync(CancellationToken.None); + + // Assert + await yamlReader.DidNotReceiveWithAnyArgs().ReadYamlAsync( + default!, + default); + } + + [Fact] + public async Task ReadPatchesAndAddonsForModAsyncReturnsWhenManifestLookupIsMissingAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var manifestUri = new Uri("https://example.test/repos.yaml"); + ILauncherContentStateStore stateStore = Substitute.For(); + ILocalLauncherContentService localContentService = Substitute.For(); + IRemoteYamlDocumentReader yamlReader = Substitute.For(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + yamlReader, + Substitute.For()); + + stateStore.Load().Returns(new LauncherContentState()); + localContentService.FindInstalledVersions(paths, Layout) + .Returns(Array.Empty()); + yamlReader.ReadYamlAsync(manifestUri, Arg.Any()) + .Returns(Task.FromResult(new ReposModsData())); + + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(true, manifestUri, paths, Layout), + CancellationToken.None); + + // Act + await service.ReadPatchesAndAddonsForModAsync(new ModificationReposVersion("Missing"), CancellationToken.None); + + // Assert + await yamlReader.DidNotReceiveWithAnyArgs().ReadYamlAsync( + default!, + default); + } + + [Fact] + public async Task InitDataAsyncDownloadsAdvertisingMetadataAndImagesAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var advertisingUri = new Uri("https://example.test/advertising.yaml"); + var imageUri = new Uri("https://cdn.example.test/advertising.jpg"); + ILauncherContentStateStore stateStore = Substitute.For(); + ILocalLauncherContentService localContentService = Substitute.For(); + IRemoteYamlDocumentReader yamlReader = Substitute.For(); + IRemoteAssetDownloader assetDownloader = Substitute.For(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + yamlReader, + assetDownloader); + + stateStore.Load().Returns(new LauncherContentState()); + localContentService.FindInstalledVersions(paths, Layout) + .Returns(Array.Empty()); + yamlReader.ReadYamlAsync(manifestUri, Arg.Any()) + .Returns(Task.FromResult(new ReposModsData + { + AdvData = new List + { + new AdvertisingData + { + ModName = "RiseOfTheReds", + ModLink = advertisingUri.ToString(), + ImagesData = new List { imageUri.ToString() } + } + } + })); + yamlReader.ReadYamlAsync(advertisingUri, Arg.Any()) + .Returns(Task.FromResult(new ModificationReposVersion + { + ModificationType = ModificationType.Advertising, + Name = "RiseOfTheReds", + Version = "1.87" + })); + + // Act + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(true, manifestUri, paths, Layout), + CancellationToken.None); + + // Assert + ModificationVersion? advertising = service.GetAdvertising(); + advertising.Should().NotBeNull(); + advertising!.Name.Should().Be("RiseOfTheReds"); + advertising.Version.Should().Be("1.87"); + await assetDownloader.Received(1).DownloadIfMissingAsync( + imageUri, + paths.GetModificationImageFilePath("RiseOfTheReds", "0.jpg"), + Arg.Any()); + } + + [Fact] + public async Task InitDataAsyncLeavesAdvertisingEmptyWhenManifestDownloadFailsAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var advertisingUri = new Uri("https://example.test/advertising.yaml"); + ILauncherContentStateStore stateStore = Substitute.For(); + ILocalLauncherContentService localContentService = Substitute.For(); + IRemoteYamlDocumentReader yamlReader = Substitute.For(); + IRemoteAssetDownloader assetDownloader = Substitute.For(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + yamlReader, + assetDownloader); + + stateStore.Load().Returns(new LauncherContentState()); + localContentService.FindInstalledVersions(paths, Layout) + .Returns(Array.Empty()); + yamlReader.ReadYamlAsync(manifestUri, Arg.Any()) + .Returns(Task.FromResult(new ReposModsData + { + AdvData = new List + { + new AdvertisingData + { + ModName = "RiseOfTheReds", + ModLink = advertisingUri.ToString(), + ImagesData = new List { "https://cdn.example.test/advertising.jpg" } + } + } + })); + yamlReader.ReadYamlAsync(advertisingUri, Arg.Any()) + .Returns(Task.FromException(new IOException("Manifest unavailable."))); + + // Act + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(true, manifestUri, paths, Layout), + CancellationToken.None); + + // Assert + service.GetAdvertising().Should().BeNull(); + await assetDownloader.DidNotReceiveWithAnyArgs().DownloadIfMissingAsync( + default!, + default!, + default); + } + + [Fact] + public async Task SelectionQueriesReflectLoadedCatalogStateAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + ILauncherContentStateStore stateStore = Substitute.For(); + ILocalLauncherContentService localContentService = Substitute.For(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + Substitute.For(), + Substitute.For()); + + LauncherContentState catalogState = new() + { + Modifications = new List + { + CreateEntry( + LauncherContentType.Mod, + "ShockWave", + dependenceName: string.Empty, + isSelected: true, + version: "1.2", + versionSelected: true), + CreateEntry( + LauncherContentType.Mod, + "Contra", + dependenceName: string.Empty, + isSelected: false, + version: "009", + versionSelected: false) + }, + Patches = new List + { + CreateEntry( + LauncherContentType.Patch, + "BalancePatch", + dependenceName: "ShockWave", + isSelected: true, + version: "2.0", + versionSelected: true) + }, + Addons = new List + { + CreateEntry( + LauncherContentType.Addon, + "HDTextures", + dependenceName: "ShockWave", + isSelected: true, + version: "1.0", + versionSelected: true), + CreateEntry( + LauncherContentType.Addon, + "PatchAddon", + dependenceName: "BalancePatch", + isSelected: true, + version: "1.1", + versionSelected: true) + } + }; + stateStore.Load().Returns(catalogState); + localContentService.FindInstalledVersions(paths, Layout) + .Returns(GetVersions(catalogState)); + + // Act + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(false, null, paths, Layout), + CancellationToken.None); + + // Assert + service.GetSelectedModVersion().Should().Match(version => + version.Name == "ShockWave" && version.Version == "1.2"); + service.GetSelectedPatchVersion().Should().Match(version => + version.Name == "BalancePatch" && version.Version == "2.0"); + service.GetPatchesForSelectedMod().Should().ContainSingle(patch => patch.Name == "BalancePatch"); + service.GetAddonsForSelectedMod().Should().Contain(addon => addon.Name == "HDTextures") + .And.Contain(addon => addon.Name == "PatchAddon"); + service.GetSelectedModVersions().Should().ContainSingle(version => version.Name == "ShockWave"); + service.GetSelectedAddonsVersions().Should().HaveCount(2); + service.GetSelectedAddonsForSelectedMod().Should().HaveCount(2); + service.GetSelectedPatch().Should().Match(patch => patch.Name == "BalancePatch"); + service.GetAllModsVersionsList().Should().HaveCount(2); + + service.UnselectAllModifications(); + service.GetSelectedMod().Should().BeNull(); + } + + [Fact] + public async Task DeleteVersionDelegatesToLocalContentReconcilerAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + ILauncherContentStateStore stateStore = Substitute.For(); + ILocalLauncherContentService localContentService = Substitute.For(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + Substitute.For(), + Substitute.For()); + ModificationVersion version = new() + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.0", + Installed = true + }; + + stateStore.Load().Returns(new LauncherContentState()); + localContentService.FindInstalledVersions(paths, Layout) + .Returns(Array.Empty()); + + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(false, null, paths, Layout), + CancellationToken.None); + + // Act + service.DeleteVersion(version); + + // Assert + localContentService.Received(1).DeleteVersion( + paths, + Layout, + Arg.Is(state => + state.ModificationType == LauncherContentType.Mod && + state.Name == "ShockWave" && + state.Version == "1.0")); + } + + [Fact] + public async Task DownloadModificationDataFromReposAsyncAddsRemoteModAndCachesImagesAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var modUri = new Uri("https://example.test/contra.yaml"); + var imageUri = new Uri("https://cdn.example.test/contra.png"); + ILauncherContentStateStore stateStore = Substitute.For(); + ILocalLauncherContentService localContentService = Substitute.For(); + IRemoteYamlDocumentReader yamlReader = Substitute.For(); + IRemoteAssetDownloader assetDownloader = Substitute.For(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + yamlReader, + assetDownloader); + + stateStore.Load().Returns(new LauncherContentState()); + localContentService.FindInstalledVersions(paths, Layout) + .Returns(Array.Empty()); + yamlReader.ReadYamlAsync(manifestUri, Arg.Any()) + .Returns(Task.FromResult(new ReposModsData + { + modDatas = new List + { + new ModAddonsAndPatches + { + ModName = "Contra", + ModLink = modUri.ToString() + } + } + })); + yamlReader.ReadYamlAsync(modUri, Arg.Any()) + .Returns(Task.FromResult(new ModificationReposVersion + { + ModificationType = ModificationType.Mod, + Name = "Contra", + Version = "009", + UIImageSourceLink = imageUri.ToString() + })); + + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(true, manifestUri, paths, Layout), + CancellationToken.None); + + // Act + ModificationVersion downloadedVersion = await service.DownloadModificationDataFromReposAsync( + "Contra", + CancellationToken.None); + + // Assert + downloadedVersion.Name.Should().Be("Contra"); + downloadedVersion.Version.Should().Be("009"); + service.GetAllModificationsNames().Should().Contain("Contra"); + await assetDownloader.Received(1).DownloadIfMissingAsync( + imageUri, + paths.GetModificationImageFilePath("Contra", "009.png"), + Arg.Any()); + } + + [Fact] + public async Task RemoveContentVersionDeletesFolderAndCatalogVersionAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + ILauncherContentStateStore stateStore = Substitute.For(); + ILocalLauncherContentService localContentService = Substitute.For(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + Substitute.For(), + Substitute.For()); + ModificationVersion version = new() + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.0", + Installed = true + }; + stateStore.Load().Returns(new LauncherContentState()); + localContentService.FindInstalledVersions(paths, Layout) + .Returns(Array.Empty()); + + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(false, null, paths, Layout), + CancellationToken.None); + service.AddModModification(version); + + // Act + service.RemoveContentVersion(version); + + // Assert + service.GetAllModificationsNames().Should().NotContain("ShockWave"); + localContentService.Received(1).DeleteVersion( + paths, + Layout, + Arg.Is(state => + state.ModificationType == LauncherContentType.Mod && + state.Name == "ShockWave" && + state.Version == "1.0")); + } + + [Fact] + public async Task RemoveContentDeletesContentFolderAndCatalogEntryAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + ILauncherContentStateStore stateStore = Substitute.For(); + ILocalLauncherContentService localContentService = Substitute.For(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + Substitute.For(), + Substitute.For()); + ModificationVersion version = new() + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.0", + Installed = true + }; + stateStore.Load().Returns(new LauncherContentState()); + localContentService.FindInstalledVersions(paths, Layout) + .Returns(Array.Empty()); + + await service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(false, null, paths, Layout), + CancellationToken.None); + service.AddModModification(version); + + // Act + service.RemoveContent(version); + + // Assert + service.GetAllModificationsNames().Should().NotContain("ShockWave"); + localContentService.Received(1).DeleteContent( + paths, + Layout, + Arg.Is(state => + state.ModificationType == LauncherContentType.Mod && + state.Name == "ShockWave" && + state.Version == "1.0")); + localContentService.DidNotReceive().DeleteVersion( + Arg.Any(), + Arg.Any(), + Arg.Any()); + } + + [Fact] + public void SaveLauncherDataPersistsOnlyInstalledOrSelectedVersions() + { + // Arrange + ILauncherContentStateStore stateStore = Substitute.For(); + ILocalLauncherContentService localContentService = Substitute.For(); + IRemoteYamlDocumentReader yamlReader = Substitute.For(); + IRemoteAssetDownloader assetDownloader = Substitute.For(); + LauncherContentCatalogService service = CreateService( + stateStore, + localContentService, + yamlReader, + assetDownloader); + + service.AddModModification(new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.0", + Installed = true, + IsSelected = true + }); + service.AddModModification(new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "Contra", + Version = "2.0" + }); + + // Act + service.SaveLauncherData(); + + // Assert + stateStore.Received(1).Save(Arg.Is(state => + state.Modifications.Count == 1 && + state.Modifications[0].Name == "ShockWave" && + state.Modifications[0].ModificationVersions.Count == 1 && + state.Modifications[0].ModificationVersions[0].Version == "1.0" && + state.Modifications[0].ModificationVersions[0].Installed && + state.Modifications[0].ModificationVersions[0].IsSelected)); + } + + private static LauncherContentLayout Layout { get; } = new LauncherContentLayout("Addons", "Patches"); + + private static LauncherContentCatalogService CreateService( + ILauncherContentStateStore stateStore, + ILocalLauncherContentService localContentService, + IRemoteYamlDocumentReader yamlReader, + IRemoteAssetDownloader assetDownloader) + { + var stateMapper = new LauncherContentStateMapper(); + var selectionService = new LauncherContentSelectionService(); + + return new LauncherContentCatalogService( + stateStore, + new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance), + new LauncherCatalogImageCache( + assetDownloader, + NullLogger.Instance), + stateMapper, + new LauncherLocalContentReconciler( + localContentService, + stateMapper, + selectionService, + NullLogger.Instance), + selectionService, + NullLogger.Instance); + } + + private static LauncherPaths CreatePaths(string root) + { + return new LauncherPaths( + root, + Path.Combine(root, "GenLauncherGO"), + Path.Combine(root, "GenLauncherGO", "Runtime"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Cache"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Cache", "Images"), + Path.Combine(root, "GenLauncherGO", "Mods"), + Path.Combine(root, "GenLauncherGO", "Logs"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Temp"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Deployment")); + } + + private static ModificationReposVersion CreateRemoteVersion( + string name, + string version, + ModificationType modificationType, + string dependenceName = "") + { + return new ModificationReposVersion + { + ModificationType = modificationType, + Name = name, + Version = version, + DependenceName = dependenceName + }; + } + + private static LauncherContentEntryState CreateEntry( + LauncherContentType type, + string name, + string dependenceName, + bool isSelected, + string version, + bool versionSelected) + { + return new LauncherContentEntryState + { + ModificationType = type, + Name = name, + DependenceName = dependenceName, + Installed = true, + IsSelected = isSelected, + ModificationVersions = new List + { + new LauncherContentVersionState + { + ModificationType = type, + Name = name, + Version = version, + DependenceName = dependenceName, + Installed = true, + IsSelected = versionSelected + } + } + }; + } + + private static IReadOnlyList GetVersions(LauncherContentState state) + { + var versions = new List(); + foreach (LauncherContentEntryState entry in state.Modifications) + { + versions.AddRange(entry.ModificationVersions); + } + + foreach (LauncherContentEntryState entry in state.Patches) + { + versions.AddRange(entry.ModificationVersions); + } + + foreach (LauncherContentEntryState entry in state.Addons) + { + versions.AddRange(entry.ModificationVersions); + } + + return versions; + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentSelectionServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentSelectionServiceTests.cs new file mode 100644 index 00000000..7c05d3db --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentSelectionServiceTests.cs @@ -0,0 +1,186 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Services; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class LauncherContentSelectionServiceTests +{ + [Fact] + public void UnselectAllModificationsClearsSelectedModifications() + { + // Arrange + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.0", + IsSelected = true + }); + launcherData.AddOrUpdate(new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "Contra", + Version = "1.0", + IsSelected = true + }); + var selectionService = new LauncherContentSelectionService(); + + // Act + selectionService.UnselectAllModifications(launcherData); + + // Assert + launcherData.Modifications.Should().OnlyContain(mod => !mod.IsSelected); + } + + [Fact] + public void SelectedModificationQueriesReturnSelectedCardAndVersions() + { + // Arrange + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.0", + IsSelected = true + }); + launcherData.AddOrUpdate(new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.1", + }); + var selectionService = new LauncherContentSelectionService(); + + // Act + GameModification? selectedMod = selectionService.GetSelectedMod(launcherData); + ModificationVersion? selectedVersion = selectionService.GetSelectedModVersion(launcherData); + IReadOnlyList selectedVersions = selectionService.GetSelectedModVersions(launcherData); + + // Assert + selectedMod.Should().NotBeNull(); + selectedMod!.Name.Should().Be("ShockWave"); + selectedVersion.Should().NotBeNull(); + selectedVersion!.Version.Should().Be("1.0"); + selectedVersions.Select(version => version.Version).Should().BeEquivalentTo("1.0", "1.1"); + } + + [Fact] + public void OriginalGameQueriesUseOriginalGameDependenciesWhenNoModIsSelected() + { + // Arrange + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(new ModificationVersion + { + ModificationType = ModificationType.Patch, + Name = "Original Patch", + Version = "1.0", + DependenceName = "Original game", + IsSelected = true + }); + launcherData.AddOrUpdate(new ModificationVersion + { + ModificationType = ModificationType.Addon, + Name = "Original Addon", + Version = "2.0", + DependenceName = "Original game", + IsSelected = true + }); + launcherData.AddOrUpdate(new ModificationVersion + { + ModificationType = ModificationType.Addon, + Name = "Patch Addon", + Version = "3.0", + DependenceName = "Original Patch", + IsSelected = true + }); + var selectionService = new LauncherContentSelectionService(); + + // Act + IReadOnlyList patches = selectionService.GetPatchesForSelectedMod(launcherData); + IReadOnlyList addons = selectionService.GetAddonsForSelectedMod(launcherData); + IReadOnlyList selectedAddons = selectionService.GetSelectedAddonsForSelectedMod(launcherData); + + // Assert + patches.Select(patch => patch.Name).Should().Equal("Original Patch"); + addons.Select(addon => addon.Name).Should().BeEquivalentTo("Original Addon", "Patch Addon"); + selectedAddons.Select(addon => addon.Name).Should().BeEquivalentTo("Original Addon", "Patch Addon"); + } + + [Fact] + public void GetAddonsForSelectedModIncludesPatchDependentAddons() + { + // Arrange + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.0", + IsSelected = true + }); + launcherData.AddOrUpdate(new ModificationVersion + { + ModificationType = ModificationType.Patch, + Name = "ShockWave Patch", + Version = "1.1", + DependenceName = "ShockWave", + IsSelected = true + }); + launcherData.AddOrUpdate(new ModificationVersion + { + ModificationType = ModificationType.Addon, + Name = "Patch Addon", + Version = "2.0", + DependenceName = "ShockWave Patch", + IsSelected = true + }); + launcherData.AddOrUpdate(new ModificationVersion + { + ModificationType = ModificationType.Addon, + Name = "Mod Addon", + Version = "3.0", + DependenceName = "ShockWave" + }); + var selectionService = new LauncherContentSelectionService(); + + // Act + IReadOnlyList addons = selectionService.GetAddonsForSelectedMod(launcherData); + + // Assert + addons.Select(addon => addon.Name).Should().BeEquivalentTo("Patch Addon", "Mod Addon"); + selectionService.GetSelectedAddonsVersions(launcherData) + .Should().ContainSingle(version => version.Name == "Patch Addon"); + } + + [Fact] + public void SelectionQueriesThrowWhenLauncherDataIsNull() + { + // Arrange + var selectionService = new LauncherContentSelectionService(); + + // Act + Action unselect = () => selectionService.UnselectAllModifications(null!); + Action selectedMod = () => selectionService.GetSelectedMod(null!); + Action names = () => selectionService.GetAllModificationsNames(null!); + Action patches = () => selectionService.GetPatchesForSelectedMod(null!); + Action addons = () => selectionService.GetAddonsForSelectedMod(null!); + Action allVersions = () => selectionService.GetAllModsVersionsList(null!); + Action addonVersions = () => selectionService.GetAddonVersionsForModList(null!, "ShockWave"); + Action patchVersions = () => selectionService.GetPatchVersionsForModList(null!, "ShockWave"); + + // Assert + unselect.Should().Throw().WithParameterName("launcherData"); + selectedMod.Should().Throw().WithParameterName("launcherData"); + names.Should().Throw().WithParameterName("launcherData"); + patches.Should().Throw().WithParameterName("launcherData"); + addons.Should().Throw().WithParameterName("launcherData"); + allVersions.Should().Throw().WithParameterName("launcherData"); + addonVersions.Should().Throw().WithParameterName("launcherData"); + patchVersions.Should().Throw().WithParameterName("launcherData"); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentStateMapperTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentStateMapperTests.cs new file mode 100644 index 00000000..9fe33e3f --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentStateMapperTests.cs @@ -0,0 +1,320 @@ +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Services; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class LauncherContentStateMapperTests +{ + [Fact] + public void ToLauncherDataRestoresSelectedInstalledContentState() + { + // Arrange + var mapper = new LauncherContentStateMapper(); + var state = new LauncherContentState + { + Modifications = new List + { + CreateEntry("ShockWave", string.Empty, LauncherContentType.Mod, "1.0", true) + }, + Patches = new List + { + CreateEntry("ShockWave Patch", "ShockWave", LauncherContentType.Patch, "1.1", true) + }, + Addons = new List + { + CreateEntry("Music Pack", "ShockWave Patch", LauncherContentType.Addon, "2.0", true) + } + }; + + // Act + var launcherData = mapper.ToLauncherData(state); + + // Assert + ModificationVersion modVersion = launcherData.Modifications.Should().ContainSingle().Subject + .ModificationVersions.Should().ContainSingle().Subject; + launcherData.Modifications[0].IsSelected.Should().BeTrue(); + launcherData.Modifications[0].NumberInList.Should().Be(4); + modVersion.Name.Should().Be("ShockWave"); + modVersion.ModificationType.Should().Be(ModificationType.Mod); + modVersion.Installed.Should().BeTrue(); + modVersion.IsSelected.Should().BeTrue(); + + ModificationVersion patchVersion = launcherData.Patches.Should().ContainSingle().Subject + .ModificationVersions.Should().ContainSingle().Subject; + patchVersion.Name.Should().Be("ShockWave Patch"); + patchVersion.DependenceName.Should().Be("ShockWave"); + patchVersion.ModificationType.Should().Be(ModificationType.Patch); + + ModificationVersion addonVersion = launcherData.Addons.Should().ContainSingle().Subject + .ModificationVersions.Should().ContainSingle().Subject; + addonVersion.Name.Should().Be("Music Pack"); + addonVersion.DependenceName.Should().Be("ShockWave Patch"); + addonVersion.ModificationType.Should().Be(ModificationType.Addon); + } + + [Fact] + public void ToLauncherDataRestoresPersistedEntryOrder() + { + // Arrange + var mapper = new LauncherContentStateMapper(); + var state = new LauncherContentState + { + Modifications = new List + { + CreateEntry("Second", string.Empty, LauncherContentType.Mod, "1.0", false, numberInList: 1), + CreateEntry("First", string.Empty, LauncherContentType.Mod, "1.0", false, numberInList: 0) + } + }; + + // Act + var launcherData = mapper.ToLauncherData(state); + + // Assert + launcherData.Modifications + .OrderBy(modification => modification.NumberInList) + .Select(modification => modification.Name) + .Should() + .Equal("First", "Second"); + } + + [Fact] + public void ToLauncherDataDoesNotSelectEntryFromStaleVersionSelection() + { + // Arrange + var mapper = new LauncherContentStateMapper(); + var state = new LauncherContentState + { + Modifications = new List + { + CreateEntry("ShockWave", string.Empty, LauncherContentType.Mod, "1.0", false, versionSelected: true) + } + }; + + // Act + var launcherData = mapper.ToLauncherData(state); + + // Assert + GameModification modification = launcherData.Modifications.Should().ContainSingle().Subject; + modification.IsSelected.Should().BeFalse(); + modification.ModificationVersions.Should().ContainSingle().Which.IsSelected.Should().BeFalse(); + } + + [Fact] + public void ToLauncherDataUsesEntryTypeForIncompleteLegacyChildVersionRecords() + { + // Arrange + var mapper = new LauncherContentStateMapper(); + var state = new LauncherContentState + { + Addons = new List + { + new LauncherContentEntryState + { + Name = "Compatibility Addon", + DependenceName = "ShockWave", + ModificationType = LauncherContentType.Addon, + ModificationVersions = new List + { + new LauncherContentVersionState + { + Version = "1.0", + Installed = true, + ContentSourceKind = ContentSourceKind.Manual + } + } + } + } + }; + + // Act + var launcherData = mapper.ToLauncherData(state); + + // Assert + ModificationVersion version = launcherData.Addons.Should().ContainSingle().Subject + .ModificationVersions.Should().ContainSingle().Subject; + version.Name.Should().Be("Compatibility Addon"); + version.DependenceName.Should().Be("ShockWave"); + version.ModificationType.Should().Be(ModificationType.Addon); + version.ContentSourceKind.Should().Be(ContentSourceKind.Manual); + } + + [Fact] + public void ToLauncherDataRestoresAdvertisingVersionRecords() + { + // Arrange + var mapper = new LauncherContentStateMapper(); + var state = new LauncherContentState + { + Modifications = new List + { + new LauncherContentEntryState + { + Name = "Featured", + ModificationType = LauncherContentType.Advertising, + ModificationVersions = new List + { + new LauncherContentVersionState + { + Name = "Featured", + Version = "2.0", + ModificationType = LauncherContentType.Advertising, + Installed = true + } + } + } + } + }; + + // Act + var launcherData = mapper.ToLauncherData(state); + + // Assert + ModificationVersion version = launcherData.Modifications.Should().ContainSingle().Subject + .ModificationVersions.Should().ContainSingle().Subject; + version.ModificationType.Should().Be(ModificationType.Advertising); + } + + [Fact] + public void ToLauncherContentStatePersistsOnlyInstalledOrSelectedVersions() + { + // Arrange + var mapper = new LauncherContentStateMapper(); + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "Installed", + Version = "1.0", + Installed = true + }); + launcherData.AddOrUpdate(new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "RemoteOnly", + Version = "2.0" + }); + + // Act + var state = mapper.ToLauncherContentState(launcherData); + + // Assert + LauncherContentEntryState entry = state.Modifications.Should().ContainSingle().Subject; + entry.Name.Should().Be("Installed"); + entry.IsSelected.Should().BeFalse(); + entry.ModificationVersions.Should().ContainSingle().Which.Version.Should().Be("1.0"); + entry.ModificationVersions[0].IsSelected.Should().BeFalse(); + } + + [Fact] + public void ToLauncherContentStateDoesNotPersistVersionSelectionForUnselectedEntry() + { + // Arrange + var mapper = new LauncherContentStateMapper(); + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "Installed", + Version = "1.0", + Installed = true, + IsSelected = true + }); + launcherData.Modifications[0].IsSelected = false; + + // Act + var state = mapper.ToLauncherContentState(launcherData); + + // Assert + LauncherContentEntryState entry = state.Modifications.Should().ContainSingle().Subject; + entry.IsSelected.Should().BeFalse(); + entry.ModificationVersions.Should().ContainSingle().Which.IsSelected.Should().BeFalse(); + } + + [Fact] + public void ToLauncherContentStatePersistsEntryOrder() + { + // Arrange + var mapper = new LauncherContentStateMapper(); + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.0", + Installed = true + }); + launcherData.Modifications[0].NumberInList = 7; + + // Act + var state = mapper.ToLauncherContentState(launcherData); + + // Assert + state.Modifications.Should().ContainSingle().Which.NumberInList.Should().Be(7); + } + + [Fact] + public void ToLauncherContentStatePersistsAdvertisingContentType() + { + // Arrange + var mapper = new LauncherContentStateMapper(); + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(new ModificationVersion + { + ModificationType = ModificationType.Advertising, + Name = "Featured", + Version = "2.0", + Installed = true + }); + + // Act + var state = mapper.ToLauncherContentState(launcherData); + + // Assert + LauncherContentVersionState version = state.Modifications.Should().ContainSingle().Subject + .ModificationVersions.Should().ContainSingle().Subject; + version.ModificationType.Should().Be(LauncherContentType.Advertising); + } + + [Fact] + public void GetFallbackContentTypeMapsAdvertisingAndUnknownValues() + { + // Arrange + var mapper = new LauncherContentStateMapper(); + + // Act and Assert + mapper.GetFallbackContentType(ModificationType.Advertising).Should().Be(LauncherContentType.Advertising); + mapper.GetFallbackContentType((ModificationType)999).Should().Be(LauncherContentType.Mod); + } + + private static LauncherContentEntryState CreateEntry( + string name, + string dependenceName, + LauncherContentType contentType, + string version, + bool selected, + bool? versionSelected = null, + int numberInList = 4) + { + return new LauncherContentEntryState + { + Name = name, + DependenceName = dependenceName, + ModificationType = contentType, + IsSelected = selected, + NumberInList = numberInList, + ModificationVersions = new List + { + new LauncherContentVersionState + { + Version = version, + Installed = true, + IsSelected = versionSelected ?? selected, + ContentSourceKind = ContentSourceKind.Manual + } + } + }; + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherLocalContentReconcilerTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherLocalContentReconcilerTests.cs new file mode 100644 index 00000000..4abd8c29 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherLocalContentReconcilerTests.cs @@ -0,0 +1,179 @@ +using System.Collections.Generic; +using System.IO; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class LauncherLocalContentReconcilerTests +{ + [Fact] + public void ReconcileAddsUnregisteredLocalVersions() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + ILocalLauncherContentService localContentService = Substitute.For(); + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + var launcherData = new LauncherData(); + localContentService.FindInstalledVersions(paths, Layout) + .Returns(new List + { + new LauncherContentVersionState + { + ModificationType = LauncherContentType.Mod, + Name = "Local Only", + Version = "1.0", + Installed = true + } + }); + + // Act + reconciler.Reconcile(launcherData, new List(), paths, Layout); + + // Assert + launcherData.Modifications.Should().ContainSingle(mod => mod.Name == "Local Only"); + localContentService.DidNotReceive().VersionFolderContainsFiles( + paths, + Layout, + Arg.Any()); + } + + [Fact] + public void ReconcileMarksMissingRemoteVersionsUninstalledAndDeletesMissingLocalOnlyVersions() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + ILocalLauncherContentService localContentService = Substitute.For(); + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + var remoteVersion = new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "Remote", + Version = "1.0", + Installed = true + }; + var localOnlyVersion = new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "Local Only", + Version = "2.0", + Installed = true + }; + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(remoteVersion); + launcherData.AddOrUpdate(localOnlyVersion); + localContentService.FindInstalledVersions(paths, Layout) + .Returns(new List()); + + // Act + reconciler.Reconcile( + launcherData, + new List { new ModificationVersion(remoteVersion) }, + paths, + Layout); + + // Assert + launcherData.Modifications.Should().ContainSingle(mod => mod.Name == "Remote"); + launcherData.Modifications[0].ModificationVersions.Should().ContainSingle().Which.Installed.Should().BeFalse(); + localContentService.DidNotReceive().VersionFolderContainsFiles( + paths, + Layout, + Arg.Any()); + localContentService.Received(1).DeleteImagesIfUnused( + paths, + Arg.Is(version => version.Name == "Local Only"), + Arg.Any()); + } + + [Fact] + public void ReconcileKeepsInstalledChildVersionWhenVersionFolderStillExists() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + ILocalLauncherContentService localContentService = Substitute.For(); + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + var modVersion = new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2", + Installed = true + }; + var addonVersion = new ModificationVersion + { + ModificationType = ModificationType.Addon, + Name = "HD", + Version = "1.0", + DependenceName = "ShockWave", + Installed = true + }; + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(modVersion); + launcherData.AddOrUpdate(addonVersion); + localContentService.FindInstalledVersions(paths, Layout) + .Returns(new List + { + new LauncherContentVersionState + { + ModificationType = LauncherContentType.Mod, + Name = "ShockWave", + Version = "1.2", + Installed = true + } + }); + localContentService.VersionFolderExists( + paths, + Layout, + Arg.Is(version => + version.ModificationType == LauncherContentType.Addon && + version.Name == "HD" && + version.Version == "1.0" && + version.DependenceName == "ShockWave")) + .Returns(true); + + // Act + reconciler.Reconcile(launcherData, new List(), paths, Layout); + + // Assert + launcherData.Addons.Should().ContainSingle(addon => addon.Name == "HD"); + launcherData.Addons[0].ModificationVersions.Should().ContainSingle().Which.Installed.Should().BeTrue(); + localContentService.DidNotReceive().DeleteImagesIfUnused( + paths, + Arg.Is(version => version.Name == "HD"), + Arg.Any()); + } + + private static LauncherContentLayout Layout { get; } = new LauncherContentLayout("Addons", "Patches"); + + private static LauncherLocalContentReconciler CreateReconciler( + ILocalLauncherContentService localContentService) + { + var stateMapper = new LauncherContentStateMapper(); + return new LauncherLocalContentReconciler( + localContentService, + stateMapper, + new LauncherContentSelectionService(), + NullLogger.Instance); + } + + private static LauncherPaths CreatePaths(string root) + { + return new LauncherPaths( + root, + Path.Combine(root, "GenLauncherGO"), + Path.Combine(root, "GenLauncherGO", "Runtime"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Cache"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Cache", "Images"), + Path.Combine(root, "GenLauncherGO", "Mods"), + Path.Combine(root, "GenLauncherGO", "Logs"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Temp"), + Path.Combine(root, "GenLauncherGO", "Runtime", "Deployment")); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/RemoteLauncherCatalogClientTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/RemoteLauncherCatalogClientTests.cs new file mode 100644 index 00000000..ebbed042 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/RemoteLauncherCatalogClientTests.cs @@ -0,0 +1,255 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Remote; +using GenLauncherGO.Infrastructure.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class RemoteLauncherCatalogClientTests +{ + [Fact] + public async Task DownloadInstalledModDataAsyncReadsInstalledModsAndPreservesPartialFailuresAsync() + { + // Arrange + IRemoteYamlDocumentReader yamlReader = Substitute.For(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var shockwaveUri = new Uri("https://example.test/shockwave.yaml"); + var brokenUri = new Uri("https://example.test/broken.yaml"); + var contraUri = new Uri("https://example.test/contra.yaml"); + var catalog = new RemoteLauncherCatalog( + Array.Empty(), + new List + { + new("ShockWave", shockwaveUri.ToString(), Array.Empty(), Array.Empty()), + new("Broken", brokenUri.ToString(), Array.Empty(), Array.Empty()), + new("Contra", contraUri.ToString(), Array.Empty(), Array.Empty()) + }, + Array.Empty(), + Array.Empty(), + string.Empty); + yamlReader.ReadYamlAsync(shockwaveUri, Arg.Any()) + .Returns(Task.FromResult(new ModificationReposVersion + { + Name = "ShockWave", + Version = "1.2" + })); + yamlReader.ReadYamlAsync(brokenUri, Arg.Any()) + .Returns(Task.FromException(new InvalidOperationException("Broken manifest"))); + + // Act + IReadOnlyList result = + await client.DownloadInstalledModDataAsync( + catalog, + new[] { "shockwave", "broken" }, + CancellationToken.None); + + // Assert + RemoteModificationManifest entry = result.Should().ContainSingle().Subject; + entry.Content.Name.Should().Be("ShockWave"); + entry.Content.Version.Should().Be("1.2"); + entry.PatchManifestUrls.Should().BeEmpty(); + await yamlReader.DidNotReceive().ReadYamlAsync( + contraUri, + Arg.Any()); + } + + [Fact] + public async Task ReadChildManifestsAsyncReturnsSuccessfulChildrenWhenOneChildFailsAsync() + { + // Arrange + IRemoteYamlDocumentReader yamlReader = Substitute.For(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var patchUri = new Uri("https://example.test/patch.yaml"); + var missingUri = new Uri("https://example.test/missing.yaml"); + yamlReader.ReadYamlAsync(patchUri, Arg.Any()) + .Returns(Task.FromResult(new ModificationReposVersion + { + Name = "Patch", + Version = "1.0" + })); + yamlReader.ReadYamlAsync(missingUri, Arg.Any()) + .Returns(Task.FromException(new InvalidOperationException("Missing manifest"))); + + // Act + IReadOnlyList result = await client.ReadChildManifestsAsync( + new[] { patchUri.ToString(), missingUri.ToString() }, + CancellationToken.None); + + // Assert + result.Should().ContainSingle().Which.Name.Should().Be("Patch"); + } + + [Fact] + public async Task ReadCatalogAsyncMapsThirdPartyManifestToNormalizedCatalogAsync() + { + // Arrange + IRemoteYamlDocumentReader yamlReader = Substitute.For(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var manifestUri = new Uri("https://example.test/repos.yaml"); + yamlReader.ReadYamlAsync(manifestUri, Arg.Any()) + .Returns(Task.FromResult(new ReposModsData + { + AdvData = + { + new AdvertisingData + { + ModName = "Featured", + ModLink = "https://example.test/featured.yaml", + ImagesData = { "https://cdn.example.test/featured.png" } + } + }, + modDatas = + { + new ModAddonsAndPatches + { + ModName = "ShockWave", + ModLink = "https://example.test/shockwave.yaml", + ModPatches = { "https://example.test/shockwave-patch.yaml" }, + ModAddons = { "https://example.test/shockwave-addon.yaml" } + } + }, + originalGameAddons = { "https://example.test/original-addon.yaml" }, + originalGamePatches = { "https://example.test/original-patch.yaml" }, + LauncherVersion = "1.2.3" + })); + + // Act + RemoteLauncherCatalog catalog = await client.ReadCatalogAsync(manifestUri, CancellationToken.None); + + // Assert + catalog.AdvertisingEntries.Should().ContainSingle().Which.ImageUrls.Should() + .ContainSingle("https://cdn.example.test/featured.png"); + catalog.Modifications.Should().ContainSingle().Which.PatchManifestUrls.Should() + .ContainSingle("https://example.test/shockwave-patch.yaml"); + catalog.OriginalGameAddonManifestUrls.Should().ContainSingle("https://example.test/original-addon.yaml"); + catalog.OriginalGamePatchManifestUrls.Should().ContainSingle("https://example.test/original-patch.yaml"); + catalog.LauncherVersion.Should().Be("1.2.3"); + } + + [Fact] + public void GetModificationNamesReturnsCatalogModificationNames() + { + // Arrange + IRemoteYamlDocumentReader yamlReader = Substitute.For(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var catalog = new RemoteLauncherCatalog( + Array.Empty(), + new List + { + new("ShockWave", "https://example.test/shockwave.yaml", Array.Empty(), Array.Empty()), + new("Contra", "https://example.test/contra.yaml", Array.Empty(), Array.Empty()) + }, + Array.Empty(), + Array.Empty(), + string.Empty); + + // Act + IReadOnlyList names = client.GetModificationNames(catalog); + + // Assert + names.Should().Equal("ShockWave", "Contra"); + } + + [Fact] + public async Task DownloadModDataByNameAsyncReadsReferenceCaseInsensitivelyAsync() + { + // Arrange + IRemoteYamlDocumentReader yamlReader = Substitute.For(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var shockwaveUri = new Uri("https://example.test/shockwave.yaml"); + string patchUrl = "https://example.test/shockwave-patch.yaml"; + string addonUrl = "https://example.test/shockwave-addon.yaml"; + var catalog = new RemoteLauncherCatalog( + Array.Empty(), + new List + { + new("ShockWave", shockwaveUri.ToString(), new[] { patchUrl }, new[] { addonUrl }) + }, + Array.Empty(), + Array.Empty(), + string.Empty); + yamlReader.ReadYamlAsync(shockwaveUri, Arg.Any()) + .Returns(Task.FromResult(new ModificationReposVersion + { + Name = "ShockWave", + Version = "1.2" + })); + + // Act + RemoteModificationManifest result = await client.DownloadModDataByNameAsync( + catalog, + "shockwave", + CancellationToken.None); + + // Assert + result.Content.Name.Should().Be("ShockWave"); + result.Content.Version.Should().Be("1.2"); + result.PatchManifestUrls.Should().ContainSingle(patchUrl); + result.AddonManifestUrls.Should().ContainSingle(addonUrl); + } + + [Fact] + public async Task DownloadAdvertisingInfoAsyncReturnsManifestWhenReadSucceedsAsync() + { + // Arrange + IRemoteYamlDocumentReader yamlReader = Substitute.For(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var manifestUri = new Uri("https://example.test/featured.yaml"); + yamlReader.ReadYamlAsync(manifestUri, Arg.Any()) + .Returns(Task.FromResult(new ModificationReposVersion + { + Name = "Featured", + Version = "2.0", + UIImageSourceLink = "https://cdn.example.test/featured.png" + })); + + // Act + RemoteContentManifest? result = await client.DownloadAdvertisingInfoAsync( + manifestUri.ToString(), + CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result!.Name.Should().Be("Featured"); + result.Version.Should().Be("2.0"); + result.ImageSourceLink.Should().Be("https://cdn.example.test/featured.png"); + } + + [Fact] + public async Task DownloadAdvertisingInfoAsyncReturnsNullWhenReadFailsAsync() + { + // Arrange + IRemoteYamlDocumentReader yamlReader = Substitute.For(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var manifestUri = new Uri("https://example.test/featured.yaml"); + yamlReader.ReadYamlAsync(manifestUri, Arg.Any()) + .Returns(Task.FromException(new InvalidOperationException("Missing manifest."))); + + // Act + RemoteContentManifest? result = await client.DownloadAdvertisingInfoAsync( + manifestUri.ToString(), + CancellationToken.None); + + // Assert + result.Should().BeNull(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/YamlLauncherContentStateStoreTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/YamlLauncherContentStateStoreTests.cs new file mode 100644 index 00000000..1de16b42 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/YamlLauncherContentStateStoreTests.cs @@ -0,0 +1,44 @@ +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Infrastructure.Persistence.Services; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class YamlLauncherContentStateStoreTests +{ + [Fact] + public void LoadUsesEmptyContentStateAsDefaultDocument() + { + // Arrange + IYamlDocumentStore documentStore = + Substitute.For>(); + documentStore.Load(Arg.Any()) + .Returns(call => call.Arg()); + var store = new YamlLauncherContentStateStore(documentStore); + + // Act + LauncherContentState state = store.Load(); + + // Assert + state.Modifications.Should().BeEmpty(); + state.Addons.Should().BeEmpty(); + state.Patches.Should().BeEmpty(); + documentStore.Received(1).Load(Arg.Any()); + } + + [Fact] + public void SaveDelegatesToYamlDocumentStore() + { + // Arrange + IYamlDocumentStore documentStore = + Substitute.For>(); + var store = new YamlLauncherContentStateStore(documentStore); + var state = new LauncherContentState(); + + // Act + store.Save(state); + + // Assert + documentStore.Received(1).Save(state); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Support/RemoteLauncherCatalogMapperTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Support/RemoteLauncherCatalogMapperTests.cs new file mode 100644 index 00000000..667cbdf9 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Support/RemoteLauncherCatalogMapperTests.cs @@ -0,0 +1,28 @@ +using GenLauncherGO.Infrastructure.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Support; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Support; + +public sealed class RemoteLauncherCatalogMapperTests +{ + [Fact] + public void ToRemoteCatalogReturnsEmptyCatalogForNullManifest() + { + // Act + RemoteLauncherCatalog result = RemoteLauncherCatalogMapper.ToRemoteCatalog(null); + + // Assert + result.Should().BeSameAs(RemoteLauncherCatalog.Empty); + } + + [Fact] + public void ToRemoteContentManifestReturnsEmptyManifestForNullManifest() + { + // Act + var result = RemoteLauncherCatalogMapper.ToRemoteContentManifest(null); + + // Assert + result.Name.Should().BeEmpty(); + result.Version.Should().BeEmpty(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Persistence/Services/YamlDocumentStoreTests.cs b/GenLauncherGO.Tests/Infrastructure/Persistence/Services/YamlDocumentStoreTests.cs new file mode 100644 index 00000000..510df0b3 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Persistence/Services/YamlDocumentStoreTests.cs @@ -0,0 +1,234 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Persistence.Options; +using GenLauncherGO.Infrastructure.Persistence.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Persistence.Services; + +public sealed class YamlDocumentStoreTests +{ + [Fact] + public void Load_WhenDocumentIsMissing_ReturnsDefaultDocument() + { + // Arrange + using var directory = new TestDirectory(); + var defaultDocument = new TestDocument { Name = "default" }; + IYamlDocumentStore store = CreateStore(Path.Combine(directory.Path, "state.yaml")); + + // Act + TestDocument document = store.Load(defaultDocument); + + // Assert + document.Should().BeSameAs(defaultDocument); + } + + [Fact] + public void Load_WhenDocumentIsMalformed_ReturnsDefaultDocument() + { + // Arrange + using var directory = new TestDirectory(); + string documentPath = Path.Combine(directory.Path, "state.yaml"); + var defaultDocument = new TestDocument { Name = "default" }; + File.WriteAllText(documentPath, "Name: ["); + IYamlDocumentStore store = CreateStore(documentPath); + + // Act + TestDocument document = store.Load(defaultDocument); + + // Assert + document.Should().BeSameAs(defaultDocument); + } + + [Fact] + public void Load_WhenDefaultDocumentIsNull_Throws() + { + // Arrange + using var directory = new TestDirectory(); + IYamlDocumentStore store = CreateStore(Path.Combine(directory.Path, "state.yaml")); + + // Act + Action act = () => store.Load(null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Save_WritesDocumentThatCanBeLoaded() + { + // Arrange + using var directory = new TestDirectory(); + string documentPath = Path.Combine(directory.Path, "Runtime", "State", "state.yaml"); + IYamlDocumentStore store = CreateStore(documentPath); + var document = new TestDocument + { + Name = "ShockWave", + Version = "1.2", + Installed = true + }; + + // Act + store.Save(document); + TestDocument loadedDocument = store.Load(new TestDocument()); + + // Assert + loadedDocument.Name.Should().Be("ShockWave"); + loadedDocument.Version.Should().Be("1.2"); + loadedDocument.Installed.Should().BeTrue(); + File.Exists(documentPath).Should().BeTrue(); + } + + [Fact] + public void Save_WhenDocumentIsNull_Throws() + { + // Arrange + using var directory = new TestDirectory(); + IYamlDocumentStore store = CreateStore(Path.Combine(directory.Path, "state.yaml")); + + // Act + Action act = () => store.Save(null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void Save_WhenDocumentPathIsDirectory_DoesNotThrow() + { + // Arrange + using var directory = new TestDirectory(); + string documentPath = Path.Combine(directory.Path, "State"); + Directory.CreateDirectory(documentPath); + IYamlDocumentStore store = CreateStore(documentPath); + + // Act + Action act = () => store.Save(new TestDocument { Name = "ShockWave" }); + + // Assert + act.Should().NotThrow(); + Directory.Exists(documentPath).Should().BeTrue(); + } + + [Fact] + public void Load_DeserializesLegacyRepositoryManifestPropertyNames() + { + // Arrange + using var directory = new TestDirectory(); + string documentPath = Path.Combine(directory.Path, "repos.yaml"); + File.WriteAllText( + documentPath, + """ + AdvData: + - ModName: Sponsor + ModLink: https://example.test/sponsor.yaml + ImagesData: + - https://cdn.example.test/sponsor.png + globalAddonsData: + - https://example.test/global-addon.yaml + modDatas: + - ModName: ShockWave + ModLink: https://example.test/shockwave.yaml + ModPatches: + - https://example.test/shockwave-patch.yaml + ModAddons: + - https://example.test/shockwave-addon.yaml + originalGameAddons: + - https://example.test/original-addon.yaml + originalGamePatches: + - https://example.test/original-patch.yaml + LauncherVersion: 1.2.3 + """); + IYamlDocumentStore store = new YamlDocumentStore( + new YamlDocumentStoreOptions(documentPath), + NullLogger>.Instance); + + // Act + ReposModsData document = store.Load(new ReposModsData()); + + // Assert + document.AdvData.Should().ContainSingle().Which.ImagesData.Should() + .ContainSingle("https://cdn.example.test/sponsor.png"); + document.globalAddonsData.Should().ContainSingle("https://example.test/global-addon.yaml"); + document.modDatas.Should().ContainSingle().Which.ModAddons.Should() + .ContainSingle("https://example.test/shockwave-addon.yaml"); + document.originalGameAddons.Should().ContainSingle("https://example.test/original-addon.yaml"); + document.originalGamePatches.Should().ContainSingle("https://example.test/original-patch.yaml"); + document.LauncherVersion.Should().Be("1.2.3"); + } + + [Fact] + public void Save_PreservesThirdPartyRepositoryManifestPropertyNames() + { + // Arrange + using var directory = new TestDirectory(); + string documentPath = Path.Combine(directory.Path, "repos.yaml"); + IYamlDocumentStore store = new YamlDocumentStore( + new YamlDocumentStoreOptions(documentPath), + NullLogger>.Instance); + var document = new ReposModsData + { + AdvData = + { + new AdvertisingData + { + ModName = "Sponsor", + ModLink = "https://example.test/sponsor.yaml", + ImagesData = { "https://cdn.example.test/sponsor.png" } + } + }, + globalAddonsData = { "https://example.test/global-addon.yaml" }, + modDatas = + { + new ModAddonsAndPatches + { + ModName = "ShockWave", + ModLink = "https://example.test/shockwave.yaml", + ModPatches = { "https://example.test/shockwave-patch.yaml" }, + ModAddons = { "https://example.test/shockwave-addon.yaml" } + } + }, + originalGameAddons = { "https://example.test/original-addon.yaml" }, + originalGamePatches = { "https://example.test/original-patch.yaml" }, + LauncherVersion = "1.2.3" + }; + + // Act + store.Save(document); + string yaml = File.ReadAllText(documentPath); + + // Assert + yaml.Should().Contain("AdvData:"); + yaml.Should().Contain("globalAddonsData:"); + yaml.Should().Contain("modDatas:"); + yaml.Should().Contain("originalGameAddons:"); + yaml.Should().Contain("originalGamePatches:"); + yaml.Should().Contain("LauncherVersion:"); + yaml.Should().Contain("ModName:"); + yaml.Should().Contain("ModLink:"); + yaml.Should().Contain("ModPatches:"); + yaml.Should().Contain("ModAddons:"); + yaml.Should().NotContain("GlobalAddonsData:"); + yaml.Should().NotContain("Modifications:"); + yaml.Should().NotContain("OriginalGameAddons:"); + yaml.Should().NotContain("OriginalGamePatches:"); + } + + private static YamlDocumentStore CreateStore(string documentPath) + { + return new YamlDocumentStore( + new YamlDocumentStoreOptions(documentPath), + NullLogger>.Instance); + } + + private sealed class TestDocument + { + public string Name { get; set; } = string.Empty; + + public string Version { get; set; } = string.Empty; + + public bool Installed { get; set; } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteAssetDownloaderTests.cs b/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteAssetDownloaderTests.cs new file mode 100644 index 00000000..0bd7b624 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteAssetDownloaderTests.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Remote; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Remote; + +public sealed class HttpRemoteAssetDownloaderTests +{ + [Fact] + public async Task DownloadIfMissingAsync_DeletesStaleTemporaryFileAndDoesNotResumeAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "asset.png"); + string temporaryFilePath = destinationFilePath + ".download"; + await File.WriteAllTextAsync(temporaryFilePath, "stale"); + + RecordingFileDownloader fileDownloader = new(); + HttpRemoteAssetDownloader downloader = new( + fileDownloader, + NullLogger.Instance); + + // Act + await downloader.DownloadIfMissingAsync( + new Uri("https://example.test/asset.png"), + destinationFilePath, + CancellationToken.None); + + // Assert + fileDownloader.Requests.Should().ContainSingle() + .Which.Resume.Should().BeFalse(); + File.ReadAllText(destinationFilePath).Should().Be("fresh"); + File.Exists(temporaryFilePath).Should().BeFalse(); + } + + private sealed class RecordingFileDownloader : IResumableFileDownloader + { + public List Requests { get; } = new(); + + public async Task DownloadFileAsync( + DownloadFileRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + Requests.Add(request); + await File.WriteAllTextAsync(request.DestinationFilePath, "fresh", cancellationToken); + return new DownloadFileResult(request.DestinationFilePath, 5, false); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteConnectionProbeTests.cs b/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteConnectionProbeTests.cs new file mode 100644 index 00000000..069e7126 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteConnectionProbeTests.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Remote; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Remote; + +public sealed class HttpRemoteConnectionProbeTests +{ + [Fact] + public async Task CanConnectAsync_ReturnsTrueWhenHeadSucceedsAsync() + { + // Arrange + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.NoContent)); + HttpRemoteConnectionProbe probe = CreateProbe(handler); + + // Act + bool canConnect = await probe.CanConnectAsync( + new Uri("https://example.test/catalog.yml"), + CancellationToken.None); + + // Assert + canConnect.Should().BeTrue(); + handler.Methods.Should().Equal(HttpMethod.Head); + } + + [Fact] + public async Task CanConnectAsync_FallsBackToGetWhenHeadIsNotAllowedAsync() + { + // Arrange + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.MethodNotAllowed)); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.OK)); + HttpRemoteConnectionProbe probe = CreateProbe(handler); + + // Act + bool canConnect = await probe.CanConnectAsync( + new Uri("https://example.test/catalog.yml"), + CancellationToken.None); + + // Assert + canConnect.Should().BeTrue(); + handler.Methods.Should().Equal(HttpMethod.Head, HttpMethod.Get); + } + + [Fact] + public async Task CanConnectAsync_ReturnsFalseWhenRequestsFailAsync() + { + // Arrange + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => throw new HttpRequestException("network down")); + HttpRemoteConnectionProbe probe = CreateProbe(handler); + + // Act + bool canConnect = await probe.CanConnectAsync( + new Uri("https://example.test/catalog.yml"), + CancellationToken.None); + + // Assert + canConnect.Should().BeFalse(); + } + + [Fact] + public async Task CanConnectAsync_ReturnsFalseWhenProbeTimesOutAsync() + { + // Arrange + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => throw new TaskCanceledException("timeout")); + HttpRemoteConnectionProbe probe = CreateProbe(handler); + + // Act + bool canConnect = await probe.CanConnectAsync( + new Uri("https://example.test/catalog.yml"), + CancellationToken.None); + + // Assert + canConnect.Should().BeFalse(); + } + + private static HttpRemoteConnectionProbe CreateProbe(QueueHttpMessageHandler handler) + { + return new HttpRemoteConnectionProbe( + NullLogger.Instance, + new HttpClient(handler)); + } + + private sealed class QueueHttpMessageHandler : HttpMessageHandler + { + private readonly Queue> _responses = new(); + + public List Methods { get; } = new(); + + public void Enqueue(Func responseFactory) + { + _responses.Enqueue(responseFactory); + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Methods.Add(request.Method); + return Task.FromResult(_responses.Dequeue()(request)); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteYamlDocumentReaderTests.cs b/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteYamlDocumentReaderTests.cs new file mode 100644 index 00000000..331af109 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteYamlDocumentReaderTests.cs @@ -0,0 +1,78 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Remote; + +namespace GenLauncherGO.Tests.Infrastructure.Remote; + +public sealed class HttpRemoteYamlDocumentReaderTests +{ + [Fact] + public async Task ReadYamlAsync_DeserializesRemoteYamlAsync() + { + // Arrange + RecordingHttpMessageHandler handler = new(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("Name: ShockWave\nVersion: '1.2'\n", Encoding.UTF8), + }); + HttpRemoteYamlDocumentReader reader = new(new HttpClient(handler)); + + // Act + RemoteDocument document = await reader.ReadYamlAsync( + new Uri("https://example.test/catalog.yml"), + CancellationToken.None); + + // Assert + document.Name.Should().Be("ShockWave"); + document.Version.Should().Be("1.2"); + handler.Requests.Should().ContainSingle() + .Which.Method.Should().Be(HttpMethod.Get); + } + + [Fact] + public async Task ReadYamlAsync_ThrowsForUnsuccessfulResponseAsync() + { + // Arrange + RecordingHttpMessageHandler handler = new(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)); + HttpRemoteYamlDocumentReader reader = new(new HttpClient(handler)); + + // Act + Func act = () => reader.ReadYamlAsync( + new Uri("https://example.test/catalog.yml"), + CancellationToken.None); + + // Assert + await act.Should().ThrowAsync(); + } + + private sealed class RemoteDocument + { + public string Name { get; set; } = string.Empty; + + public string Version { get; set; } = string.Empty; + } + + private sealed class RecordingHttpMessageHandler : HttpMessageHandler + { + private readonly Func _responseFactory; + + public RecordingHttpMessageHandler(Func responseFactory) + { + _responseFactory = responseFactory; + } + + public List Requests { get; } = new(); + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Requests.Add(request); + return Task.FromResult(_responseFactory(request)); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Settings/Composition/SettingsInfrastructureServiceCollectionExtensionsTests.cs b/GenLauncherGO.Tests/Infrastructure/Settings/Composition/SettingsInfrastructureServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..5717eaa2 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Settings/Composition/SettingsInfrastructureServiceCollectionExtensionsTests.cs @@ -0,0 +1,64 @@ +using System; +using GenLauncherGO.Core.Settings.Contracts; +using GenLauncherGO.Infrastructure.Settings.Composition; +using GenLauncherGO.Infrastructure.Settings.Options; +using GenLauncherGO.Infrastructure.Settings.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Tests.Infrastructure.Settings.Composition; + +public sealed class SettingsInfrastructureServiceCollectionExtensionsTests +{ + [Fact] + public void AddGenLauncherGoSettingsInfrastructure_RegistersSettingsServices() + { + // Arrange + ServiceCollection services = new(); + services.AddLogging(); + + // Act + services.AddGenLauncherGoSettingsInfrastructure(@"C:\Launcher\preferences.yml", @"C:\Launcher\Logs"); + using ServiceProvider provider = services.BuildServiceProvider(); + + // Assert + provider.GetRequiredService().PreferencesFilePath + .Should().Be(@"C:\Launcher\preferences.yml"); + provider.GetRequiredService().LogsDirectory + .Should().Be(@"C:\Launcher\Logs"); + provider.GetRequiredService() + .Should().BeOfType(); + provider.GetRequiredService() + .Should().BeOfType(); + } + + [Fact] + public void AddGenLauncherGoSettingsInfrastructure_ThrowsForMissingServices() + { + // Arrange + ServiceCollection? services = null; + + // Act + Action act = () => services!.AddGenLauncherGoSettingsInfrastructure("preferences.yml", "Logs"); + + // Assert + act.Should().Throw(); + } + + [Theory] + [InlineData("", "Logs")] + [InlineData("preferences.yml", "")] + public void AddGenLauncherGoSettingsInfrastructure_ThrowsForMissingPaths( + string preferencesFilePath, + string logsDirectory) + { + // Arrange + ServiceCollection services = new(); + + // Act + Action act = () => services.AddGenLauncherGoSettingsInfrastructure(preferencesFilePath, logsDirectory); + + // Assert + act.Should().Throw(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Settings/Options/LauncherSettingsLinkOptionsTests.cs b/GenLauncherGO.Tests/Infrastructure/Settings/Options/LauncherSettingsLinkOptionsTests.cs new file mode 100644 index 00000000..6461ce97 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Settings/Options/LauncherSettingsLinkOptionsTests.cs @@ -0,0 +1,27 @@ +using System; +using GenLauncherGO.Infrastructure.Settings.Options; + +namespace GenLauncherGO.Tests.Infrastructure.Settings.Options; + +public sealed class LauncherSettingsLinkOptionsTests +{ + [Fact] + public void ConstructorStoresLogsDirectory() + { + // Arrange and Act + LauncherSettingsLinkOptions options = new(@"C:\Launcher\Logs"); + + // Assert + options.LogsDirectory.Should().Be(@"C:\Launcher\Logs"); + } + + [Fact] + public void ConstructorThrowsForMissingLogsDirectory() + { + // Arrange + Action act = () => new LauncherSettingsLinkOptions(" "); + + // Act and Assert + act.Should().Throw(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Settings/Services/PreferencesServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Settings/Services/PreferencesServiceTests.cs new file mode 100644 index 00000000..d04cc4e6 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Settings/Services/PreferencesServiceTests.cs @@ -0,0 +1,174 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Infrastructure.Settings.Options; +using GenLauncherGO.Infrastructure.Settings.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Settings.Services; + +public sealed class PreferencesServiceTests +{ + [Fact] + public void Current_WhenPreferencesFileIsMissing_ReturnsDefaults() + { + // Arrange + using var directory = new TestDirectory(); + string preferencesFilePath = Path.Combine(directory.Path, "LauncherPreferences.yaml"); + + // Act + PreferencesService service = CreateService(preferencesFilePath); + + // Assert + service.Current.Should().Be(new LauncherPreferences()); + } + + [Fact] + public void Current_WhenPreferencesFileIsMalformed_ReturnsDefaults() + { + // Arrange + using var directory = new TestDirectory(); + string preferencesFilePath = Path.Combine(directory.Path, "LauncherPreferences.yaml"); + File.WriteAllText(preferencesFilePath, "SelectedGameClient: ["); + + // Act + PreferencesService service = CreateService(preferencesFilePath); + + // Assert + service.Current.Should().Be(new LauncherPreferences()); + } + + [Fact] + public void Current_NormalizesNullableLegacyStrings() + { + // Arrange + using var directory = new TestDirectory(); + string preferencesFilePath = Path.Combine(directory.Path, "LauncherPreferences.yaml"); + File.WriteAllText( + preferencesFilePath, + """ + SelectedGameClient: + SelectedWorldBuilder: + GameArguments: + WorldBuilderArguments: + """); + + // Act + PreferencesService service = CreateService(preferencesFilePath); + + // Assert + service.Current.SelectedGameClient.Should().BeEmpty(); + service.Current.SelectedWorldBuilder.Should().BeEmpty(); + service.Current.GameArguments.Should().BeEmpty(); + service.Current.WorldBuilderArguments.Should().BeEmpty(); + } + + [Fact] + public void Update_PersistsPreferencesWithoutLegacySettingsFile() + { + // Arrange + using var directory = new TestDirectory(); + string preferencesFilePath = Path.Combine(directory.Path, "LauncherPreferences.yaml"); + PreferencesService service = CreateService(preferencesFilePath); + var preferences = new LauncherPreferences + { + LaunchesCount = 7, + AutoDeleteOldVersions = true, + HideLauncherAfterGameStart = true, + UseEnglishLanguage = true, + SelectedGameClient = "generalszh.exe", + SelectedWorldBuilder = "worldbuilderzh.exe", + GameArguments = "-quickstart", + WorldBuilderArguments = "-wb" + }; + + // Act + service.Update(preferences); + PreferencesService reloadedService = CreateService(preferencesFilePath); + + // Assert + reloadedService.Current.Should().Be(preferences); + File.Exists(preferencesFilePath).Should().BeTrue(); + } + + [Fact] + public void Update_WhenPreferencesAreUnchanged_DoesNotPersistOrRaisePreferencesChanged() + { + // Arrange + using var directory = new TestDirectory(); + string preferencesFilePath = Path.Combine(directory.Path, "LauncherPreferences.yaml"); + PreferencesService service = CreateService(preferencesFilePath); + int changedCount = 0; + service.PreferencesChanged += (_, _) => changedCount++; + + // Act + service.Update(new LauncherPreferences()); + + // Assert + changedCount.Should().Be(0); + File.Exists(preferencesFilePath).Should().BeFalse(); + } + + [Fact] + public void Update_WhenPreferencesChange_RaisesPreferencesChanged() + { + // Arrange + using var directory = new TestDirectory(); + string preferencesFilePath = Path.Combine(directory.Path, "LauncherPreferences.yaml"); + PreferencesService service = CreateService(preferencesFilePath); + LauncherPreferences? changedPreferences = null; + service.PreferencesChanged += (_, args) => changedPreferences = args.Preferences; + var preferences = new LauncherPreferences { GameArguments = "-quickstart" }; + + // Act + service.Update(preferences); + + // Assert + changedPreferences.Should().Be(preferences); + } + + [Fact] + public void Update_WhenPreferencesCannotBePersisted_StillUpdatesCurrentAndRaisesPreferencesChanged() + { + // Arrange + using var directory = new TestDirectory(); + string preferencesFilePath = Path.Combine(directory.Path, "LauncherPreferences.yaml"); + Directory.CreateDirectory(preferencesFilePath); + PreferencesService service = CreateService(preferencesFilePath); + LauncherPreferences? changedPreferences = null; + service.PreferencesChanged += (_, args) => changedPreferences = args.Preferences; + var preferences = new LauncherPreferences { GameArguments = "-quickstart" }; + + // Act + Action act = () => service.Update(preferences); + + // Assert + act.Should().NotThrow(); + service.Current.Should().Be(preferences); + changedPreferences.Should().Be(preferences); + Directory.Exists(preferencesFilePath).Should().BeTrue(); + } + + [Fact] + public void Update_WhenPreferencesIsNull_Throws() + { + // Arrange + using var directory = new TestDirectory(); + string preferencesFilePath = Path.Combine(directory.Path, "LauncherPreferences.yaml"); + PreferencesService service = CreateService(preferencesFilePath); + + // Act + Action act = () => service.Update(null!); + + // Assert + act.Should().Throw(); + } + + private static PreferencesService CreateService(string preferencesFilePath) + { + return new PreferencesService( + new LauncherPreferencesStoreOptions(preferencesFilePath), + NullLogger.Instance); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Settings/Services/ProcessLauncherSettingsLinkServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Settings/Services/ProcessLauncherSettingsLinkServiceTests.cs new file mode 100644 index 00000000..731e5529 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Settings/Services/ProcessLauncherSettingsLinkServiceTests.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using GenLauncherGO.Infrastructure.Settings.Options; +using GenLauncherGO.Infrastructure.Settings.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Settings.Services; + +public sealed class ProcessLauncherSettingsLinkServiceTests +{ + [Fact] + public void ConstructorThrowsWhenDependenciesAreNull() + { + // Arrange + LauncherSettingsLinkOptions options = new(@"C:\Logs"); + + // Act + Action nullOptions = () => new ProcessLauncherSettingsLinkService( + null!, + NullLogger.Instance, + _ => { }); + Action nullLogger = () => new ProcessLauncherSettingsLinkService( + options, + null!, + _ => { }); + Action nullOpenShellTarget = () => new ProcessLauncherSettingsLinkService( + options, + NullLogger.Instance, + null!); + + // Assert + nullOptions.Should().Throw().WithParameterName("options"); + nullLogger.Should().Throw().WithParameterName("logger"); + nullOpenShellTarget.Should().Throw().WithParameterName("openShellTarget"); + } + + [Theory] + [InlineData("Discord", "https://discord.playgenerals.online")] + [InlineData("GitHub", "https://github.com/x64-dev/GenLauncher_GO")] + [InlineData("Donation", "https://boosty.to/genlauncher/single-payment/donation/157147?share=target_link")] + public void LinkMethodsOpenExpectedShellTarget(string linkName, string expectedTarget) + { + // Arrange + List openedTargets = new(); + ProcessLauncherSettingsLinkService service = CreateService( + @"C:\Logs", + openedTargets.Add); + + // Act + bool opened = TryOpenLink(service, linkName); + + // Assert + opened.Should().BeTrue(); + openedTargets.Should().Equal(expectedTarget); + } + + [Fact] + public void TryOpenLogsDirectoryCreatesDirectoryAndOpensIt() + { + // Arrange + using TestDirectory testDirectory = new(); + string logsPath = Path.Combine(testDirectory.Path, "Logs"); + List openedTargets = new(); + ProcessLauncherSettingsLinkService service = CreateService(logsPath, openedTargets.Add); + + // Act + bool opened = service.TryOpenLogsDirectory(); + + // Assert + opened.Should().BeTrue(); + Directory.Exists(logsPath).Should().BeTrue(); + openedTargets.Should().Equal(logsPath); + } + + [Fact] + public void TryOpenLogsDirectory_ReturnsFalseWhenLogsPathCannotBeCreated() + { + // Arrange + using TestDirectory testDirectory = new(); + string logsPath = Path.Combine(testDirectory.Path, "Logs"); + File.WriteAllText(logsPath, "not a directory"); + ProcessLauncherSettingsLinkService service = new( + new LauncherSettingsLinkOptions(logsPath), + NullLogger.Instance); + + // Act + bool opened = service.TryOpenLogsDirectory(); + + // Assert + opened.Should().BeFalse(); + } + + [Fact] + public void TryOpenLinkReturnsFalseWhenShellOpenFails() + { + // Arrange + ProcessLauncherSettingsLinkService service = CreateService( + @"C:\Logs", + _ => throw new Win32Exception(5)); + + // Act + bool opened = service.TryOpenGitHubRepository(); + + // Assert + opened.Should().BeFalse(); + } + + private static ProcessLauncherSettingsLinkService CreateService( + string logsPath, + Action openShellTarget) + { + return new ProcessLauncherSettingsLinkService( + new LauncherSettingsLinkOptions(logsPath), + NullLogger.Instance, + openShellTarget); + } + + private static bool TryOpenLink(ProcessLauncherSettingsLinkService service, string linkName) + { + return linkName switch + { + "Discord" => service.TryOpenGeneralsOnlineDiscordLink(), + "GitHub" => service.TryOpenGitHubRepository(), + "Donation" => service.TryOpenOriginalAuthorDonationLink(), + _ => throw new ArgumentOutOfRangeException(nameof(linkName), linkName, null), + }; + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Shell/Composition/ShellServiceCollectionExtensionsTests.cs b/GenLauncherGO.Tests/Infrastructure/Shell/Composition/ShellServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..ea2afe66 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Shell/Composition/ShellServiceCollectionExtensionsTests.cs @@ -0,0 +1,52 @@ +using System; +using GenLauncherGO.Core.Shell.Contracts; +using GenLauncherGO.Infrastructure.Shell.Composition; +using GenLauncherGO.Infrastructure.Shell.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Tests.Infrastructure.Shell.Composition; + +public sealed class ShellServiceCollectionExtensionsTests +{ + [Fact] + public void AddGenLauncherGoShellReturnsSameServiceCollection() + { + // Arrange + IServiceCollection services = new ServiceCollection(); + + // Act + IServiceCollection returnedServices = services.AddGenLauncherGoShell(); + + // Assert + returnedServices.Should().BeSameAs(services); + } + + [Fact] + public void AddGenLauncherGoShellRegistersWindowsShellService() + { + // Arrange + IServiceCollection services = new ServiceCollection(); + services.AddLogging(); + + // Act + services.AddGenLauncherGoShell(); + using ServiceProvider provider = services.BuildServiceProvider(); + + // Assert + provider.GetRequiredService() + .Should().BeOfType(); + } + + [Fact] + public void AddGenLauncherGoShellThrowsForNullServices() + { + // Arrange + IServiceCollection? services = null; + + // Act + Action act = () => services!.AddGenLauncherGoShell(); + + // Assert + act.Should().Throw(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Shell/Services/WindowsLauncherShellServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Shell/Services/WindowsLauncherShellServiceTests.cs new file mode 100644 index 00000000..249581c3 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Shell/Services/WindowsLauncherShellServiceTests.cs @@ -0,0 +1,204 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using GenLauncherGO.Core.Shell.Models; +using GenLauncherGO.Infrastructure.Shell.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Shell.Services; + +public sealed class WindowsLauncherShellServiceTests +{ + [Fact] + public void ConstructorThrowsWhenShellOpenerIsNull() + { + // Act + Action act = () => new WindowsLauncherShellService( + NullLogger.Instance, + null!); + + // Assert + act.Should().Throw().WithParameterName("openShellTarget"); + } + + [Fact] + public void OpenUriReturnsInvalidTargetForEmptyUri() + { + // Arrange + WindowsLauncherShellService service = CreateService(); + + // Act + ShellOpenResult result = service.OpenUri(" "); + + // Assert + result.Succeeded.Should().BeFalse(); + result.FailureKind.Should().Be(ShellOpenFailureKind.InvalidTarget); + } + + [Fact] + public void OpenUriReturnsInvalidTargetForRelativeUri() + { + // Arrange + WindowsLauncherShellService service = CreateService(); + + // Act + ShellOpenResult result = service.OpenUri("not-a-uri"); + + // Assert + result.Succeeded.Should().BeFalse(); + result.FailureKind.Should().Be(ShellOpenFailureKind.InvalidTarget); + } + + [Fact] + public void OpenUriReturnsInvalidTargetForUnsupportedScheme() + { + // Arrange + WindowsLauncherShellService service = CreateService(); + + // Act + ShellOpenResult result = service.OpenUri("ftp://example.test/file.big"); + + // Assert + result.Succeeded.Should().BeFalse(); + result.FailureKind.Should().Be(ShellOpenFailureKind.InvalidTarget); + } + + [Fact] + public void OpenUriOpensNormalizedHttpTarget() + { + // Arrange + List openedTargets = new(); + WindowsLauncherShellService service = CreateService(openedTargets.Add); + + // Act + ShellOpenResult result = service.OpenUri("HTTPS://Example.Test/mods?id=1"); + + // Assert + result.Succeeded.Should().BeTrue(); + result.Target.Should().Be("https://example.test/mods?id=1"); + openedTargets.Should().Equal("https://example.test/mods?id=1"); + } + + [Fact] + public void OpenUriReturnsLaunchFailedWhenShellOpenFails() + { + // Arrange + WindowsLauncherShellService service = CreateService(_ => throw new Win32Exception(5)); + + // Act + ShellOpenResult result = service.OpenUri("https://example.test/mods"); + + // Assert + result.Succeeded.Should().BeFalse(); + result.FailureKind.Should().Be(ShellOpenFailureKind.LaunchFailed); + result.Target.Should().Be("https://example.test/mods"); + result.Message.Should().NotBeNullOrWhiteSpace(); + } + + [Fact] + public void OpenFolderReturnsInvalidTargetForEmptyFolder() + { + // Arrange + WindowsLauncherShellService service = CreateService(); + + // Act + ShellOpenResult result = service.OpenFolder(" "); + + // Assert + result.Succeeded.Should().BeFalse(); + result.FailureKind.Should().Be(ShellOpenFailureKind.InvalidTarget); + } + + [Fact] + public void OpenFolderReturnsInvalidTargetForInvalidPath() + { + // Arrange + WindowsLauncherShellService service = CreateService(); + + // Act + ShellOpenResult result = service.OpenFolder("bad\0path"); + + // Assert + result.Succeeded.Should().BeFalse(); + result.FailureKind.Should().Be(ShellOpenFailureKind.InvalidTarget); + } + + [Fact] + public void OpenFolderReturnsMissingTargetForMissingFolder() + { + // Arrange + using TestDirectory directory = new(); + WindowsLauncherShellService service = CreateService(); + string missingFolder = Path.Combine(directory.Path, "missing"); + + // Act + ShellOpenResult result = service.OpenFolder(missingFolder); + + // Assert + result.Succeeded.Should().BeFalse(); + result.FailureKind.Should().Be(ShellOpenFailureKind.MissingTarget); + } + + [Fact] + public void OpenFolderReturnsMissingTargetWhenFilesAreRequiredAndFolderIsEmpty() + { + // Arrange + using TestDirectory directory = new(); + WindowsLauncherShellService service = CreateService(); + + // Act + ShellOpenResult result = service.OpenFolder(directory.Path, requireFiles: true); + + // Assert + result.Succeeded.Should().BeFalse(); + result.FailureKind.Should().Be(ShellOpenFailureKind.MissingTarget); + } + + [Fact] + public void OpenFolderOpensExistingFolder() + { + // Arrange + using TestDirectory directory = new(); + List openedTargets = new(); + WindowsLauncherShellService service = CreateService(openedTargets.Add); + + // Act + ShellOpenResult result = service.OpenFolder(directory.Path); + + // Assert + result.Succeeded.Should().BeTrue(); + result.Target.Should().Be(Path.GetFullPath(directory.Path)); + openedTargets.Should().Equal(Path.GetFullPath(directory.Path)); + } + + [Fact] + public void OpenFolderOpensExistingFolderWhenRequiredFilesExist() + { + // Arrange + using TestDirectory directory = new(); + File.WriteAllText(Path.Combine(directory.Path, "file.txt"), "content"); + List openedTargets = new(); + WindowsLauncherShellService service = CreateService(openedTargets.Add); + + // Act + ShellOpenResult result = service.OpenFolder(directory.Path, requireFiles: true); + + // Assert + result.Succeeded.Should().BeTrue(); + openedTargets.Should().Equal(Path.GetFullPath(directory.Path)); + } + + private static WindowsLauncherShellService CreateService() + { + return CreateService(_ => { }); + } + + private static WindowsLauncherShellService CreateService(Action openShellTarget) + { + return new WindowsLauncherShellService( + NullLogger.Instance, + openShellTarget); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Startup/FileSystemLauncherPathResolverTests.cs b/GenLauncherGO.Tests/Infrastructure/Startup/FileSystemLauncherPathResolverTests.cs new file mode 100644 index 00000000..5fce1139 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Startup/FileSystemLauncherPathResolverTests.cs @@ -0,0 +1,149 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Startup; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Startup; + +public sealed class FileSystemLauncherPathResolverTests +{ + [Fact] + public void ResolveReturnsGameRootAndGeneratedLauncherRootWhenExecutableIsBesideZeroHourClient() + { + // Arrange + using var directory = new TestDirectory(); + CreateZeroHourGame(directory.Path, "generalszh.exe"); + var resolver = new FileSystemLauncherPathResolver(); + + // Act + LauncherPaths? paths = resolver.Resolve(directory.Path); + + // Assert + paths.Should().NotBeNull(); + paths!.GameDirectory.Should().Be(Path.GetFullPath(directory.Path)); + paths.LauncherDirectory.Should().Be(Path.Combine(directory.Path, "GenLauncherGO")); + paths.RuntimeDirectory.Should().Be(Path.Combine(directory.Path, "GenLauncherGO", "Runtime")); + paths.CacheDirectory.Should().Be(Path.Combine(directory.Path, "GenLauncherGO", "Runtime", "Cache")); + paths.ImagesDirectory.Should().Be(Path.Combine(directory.Path, "GenLauncherGO", "Runtime", "Cache", "Images")); + paths.ModsDirectory.Should().Be(Path.Combine(directory.Path, "GenLauncherGO", "Mods")); + paths.LogsDirectory.Should().Be(Path.Combine(directory.Path, "GenLauncherGO", "Logs")); + paths.TempDirectory.Should().Be(Path.Combine(directory.Path, "GenLauncherGO", "Runtime", "Temp")); + paths.DeploymentDirectory.Should().Be(Path.Combine(directory.Path, "GenLauncherGO", "Runtime", "Deployment")); + paths.StateDirectory.Should().Be(Path.Combine(directory.Path, "GenLauncherGO", "Runtime", "State")); + paths.LauncherDataFilePath.Should().Be( + Path.Combine(directory.Path, "GenLauncherGO", "Runtime", "State", "LauncherData.yaml")); + paths.PreferencesFilePath.Should() + .Be(Path.Combine(directory.Path, "GenLauncherGO", "LauncherPreferences.yaml")); + } + + [Fact] + public void ResolveReturnsParentGameRootAndExecutableDirectoryWhenExecutableIsInsideLauncherRoot() + { + // Arrange + using var directory = new TestDirectory(); + CreateZeroHourGame(directory.Path, "generalszh.exe"); + string launcherDirectory = Path.Combine(directory.Path, "GenLauncherGO"); + Directory.CreateDirectory(launcherDirectory); + var resolver = new FileSystemLauncherPathResolver(); + + // Act + LauncherPaths? paths = resolver.Resolve(launcherDirectory); + + // Assert + paths.Should().NotBeNull(); + paths!.GameDirectory.Should().Be(Path.GetFullPath(directory.Path)); + paths.LauncherDirectory.Should().Be(Path.GetFullPath(launcherDirectory)); + } + + [Theory] + [InlineData("Window.big", "generalsv.exe")] + [InlineData("WindowZH.big", "generalszh.exe")] + [InlineData("WindowZH.big", "generalsonlinezh.exe")] + public void ResolveAcceptsSupportedGameVariants(string windowArchiveFileName, string executableFileName) + { + // Arrange + using var directory = new TestDirectory(); + CreateFile(Path.Combine(directory.Path, "BINKW32.DLL")); + CreateFile(Path.Combine(directory.Path, windowArchiveFileName)); + CreateFile(Path.Combine(directory.Path, executableFileName)); + var resolver = new FileSystemLauncherPathResolver(); + + // Act + LauncherPaths? paths = resolver.Resolve(directory.Path); + + // Assert + paths.Should().NotBeNull(); + paths!.GameDirectory.Should().Be(Path.GetFullPath(directory.Path)); + } + + [Theory] + [InlineData("BINKW32.DLL")] + [InlineData("WindowZH.big")] + [InlineData("generalszh.exe")] + public void ResolveReturnsNullWhenRequiredZeroHourFileIsMissing(string missingFileName) + { + // Arrange + using var directory = new TestDirectory(); + foreach (string fileName in new[] { "BINKW32.DLL", "WindowZH.big", "generalszh.exe" }) + { + if (!String.Equals(fileName, missingFileName, StringComparison.OrdinalIgnoreCase)) + { + CreateFile(Path.Combine(directory.Path, fileName)); + } + } + + var resolver = new FileSystemLauncherPathResolver(); + + // Act + LauncherPaths? paths = resolver.Resolve(directory.Path); + + // Assert + paths.Should().BeNull(); + } + + [Fact] + public void PrepareLauncherDirectoriesCreatesExpectedDirectoriesAndClearsTempContents() + { + // Arrange + using var directory = new TestDirectory(); + CreateZeroHourGame(directory.Path, "generalszh.exe"); + var resolver = new FileSystemLauncherPathResolver(); + LauncherPaths paths = resolver.Resolve(directory.Path)!; + string staleTempDirectory = Path.Combine(paths.TempDirectory, "Stale"); + Directory.CreateDirectory(staleTempDirectory); + CreateFile(Path.Combine(staleTempDirectory, "download.part")); + string staleDeploymentJournal = Path.Combine(paths.DeploymentDirectory, "journal.jsonl"); + Directory.CreateDirectory(paths.DeploymentDirectory); + CreateFile(staleDeploymentJournal); + + // Act + resolver.PrepareLauncherDirectories(paths, cleanTemporaryDirectory: true); + + // Assert + Directory.Exists(paths.LauncherDirectory).Should().BeTrue(); + Directory.Exists(paths.RuntimeDirectory).Should().BeTrue(); + Directory.Exists(paths.CacheDirectory).Should().BeTrue(); + Directory.Exists(paths.ImagesDirectory).Should().BeTrue(); + Directory.Exists(paths.ModsDirectory).Should().BeTrue(); + Directory.Exists(paths.LogsDirectory).Should().BeTrue(); + Directory.Exists(paths.TempDirectory).Should().BeTrue(); + Directory.Exists(paths.DeploymentDirectory).Should().BeTrue(); + Directory.Exists(paths.StateDirectory).Should().BeTrue(); + Directory.EnumerateFileSystemEntries(paths.TempDirectory).Should().BeEmpty(); + File.Exists(staleDeploymentJournal).Should().BeTrue(); + } + + private static void CreateZeroHourGame(string directory, string executableFileName) + { + CreateFile(Path.Combine(directory, "BINKW32.DLL")); + CreateFile(Path.Combine(directory, "WindowZH.big")); + CreateFile(Path.Combine(directory, executableFileName)); + } + + private static void CreateFile(string filePath) + { + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + File.WriteAllText(filePath, String.Empty); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Startup/FileSystemLauncherStartupEnvironmentServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Startup/FileSystemLauncherStartupEnvironmentServiceTests.cs new file mode 100644 index 00000000..2794e8ca --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Startup/FileSystemLauncherStartupEnvironmentServiceTests.cs @@ -0,0 +1,137 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Startup.Models; +using GenLauncherGO.Infrastructure.Startup; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Startup; + +public sealed class FileSystemLauncherStartupEnvironmentServiceTests +{ + [Fact] + public async Task ReadAsyncReturnsZeroHourEnvironmentWithCustomVisualsAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + CreateFile(Path.Combine(directory.Path, "WindowZH.big")); + string backgroundPath = Path.Combine(directory.Path, "GlBg.png"); + CreateFile(backgroundPath); + File.WriteAllText( + Path.Combine(directory.Path, "Colors.yaml"), + """ + GenLauncherBorderColor: '#101010' + GenLauncherInactiveBorder: '#202020' + GenLauncherInactiveBorder2: '#303030' + GenLauncherActiveColor: '#404040' + GenLauncherDarkFillColor: '#505050' + GenLauncherDarkBackGround: '#606060' + GenLauncherLightBackGround: '#70606060' + GenLauncherDefaultTextColor: '#808080' + GenLauncherDownloadTextColor: '#909090' + GenLauncherListBoxSelectionColor2: '#A0A0A0' + GenLauncherListBoxSelectionColor1: '#B0B0B0' + GenLauncherButtonSelectionColor: '#C0C0C0' + """); + var service = new FileSystemLauncherStartupEnvironmentService(); + + // Act + LauncherStartupEnvironment environment = await service.ReadAsync(paths, CancellationToken.None); + + // Assert + environment.ManagedGame.Should().Be(SupportedGame.ZeroHour); + environment.CustomBackgroundImagePath.Should().Be(Path.GetFullPath(backgroundPath)); + environment.CustomColors.Should().NotBeNull(); + environment.CustomColors!.GenLauncherActiveColor.Should().Be("#404040"); + } + + [Fact] + public async Task ReadAsyncReturnsGeneralsEnvironmentWhenGeneralsArchiveExistsAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + CreateFile(Path.Combine(directory.Path, "Window.big")); + var service = new FileSystemLauncherStartupEnvironmentService(); + + // Act + LauncherStartupEnvironment environment = await service.ReadAsync(paths, CancellationToken.None); + + // Assert + environment.ManagedGame.Should().Be(SupportedGame.Generals); + environment.CustomBackgroundImagePath.Should().BeNull(); + environment.CustomColors.Should().BeNull(); + } + + [Fact] + public async Task ReadAsyncReturnsUnknownEnvironmentWhenNoGameArchiveExistsAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + var service = new FileSystemLauncherStartupEnvironmentService(); + + // Act + LauncherStartupEnvironment environment = await service.ReadAsync(paths, CancellationToken.None); + + // Assert + environment.ManagedGame.Should().Be(SupportedGame.Unknown); + environment.CustomBackgroundImagePath.Should().BeNull(); + environment.CustomColors.Should().BeNull(); + } + + [Fact] + public async Task ReadAsyncIgnoresMalformedCustomColorsAsync() + { + // Arrange + using var directory = new TestDirectory(); + LauncherPaths paths = CreatePaths(directory.Path); + File.WriteAllText(Path.Combine(directory.Path, "Colors.yaml"), "["); + var service = new FileSystemLauncherStartupEnvironmentService(); + + // Act + LauncherStartupEnvironment environment = await service.ReadAsync(paths, CancellationToken.None); + + // Assert + environment.CustomColors.Should().BeNull(); + } + + [Fact] + public async Task ReadAsyncThrowsForNullPathsAsync() + { + // Arrange + var service = new FileSystemLauncherStartupEnvironmentService(); + + // Act + Func act = async () => await service.ReadAsync(null!, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync(); + } + + private static LauncherPaths CreatePaths(string gameDirectory) + { + string launcherDirectory = Path.Combine(gameDirectory, "GenLauncherGO"); + string runtimeDirectory = Path.Combine(launcherDirectory, "Runtime"); + string cacheDirectory = Path.Combine(runtimeDirectory, "Cache"); + return new LauncherPaths( + gameDirectory, + launcherDirectory, + runtimeDirectory, + cacheDirectory, + Path.Combine(cacheDirectory, "Images"), + Path.Combine(launcherDirectory, "Mods"), + Path.Combine(launcherDirectory, "Logs"), + Path.Combine(runtimeDirectory, "Temp"), + Path.Combine(runtimeDirectory, "Deployment")); + } + + private static void CreateFile(string filePath) + { + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + File.WriteAllText(filePath, String.Empty); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Startup/WindowsLauncherHostEnvironmentServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Startup/WindowsLauncherHostEnvironmentServiceTests.cs new file mode 100644 index 00000000..743b4883 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Startup/WindowsLauncherHostEnvironmentServiceTests.cs @@ -0,0 +1,174 @@ +using System; +using System.IO; +using System.Threading; +using GenLauncherGO.Core.Startup.Contracts; +using GenLauncherGO.Infrastructure.Startup; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Startup; + +public sealed class WindowsLauncherHostEnvironmentServiceTests +{ + [Fact] + public void ConstructorThrowsWhenLoggerIsNull() + { + // Act + Action act = () => new WindowsLauncherHostEnvironmentService(null!); + + // Assert + act.Should().Throw().WithParameterName("logger"); + } + + [Fact] + public void GetExecutableDirectoryReturnsExistingDirectory() + { + // Arrange + var service = new WindowsLauncherHostEnvironmentService(); + + // Act + string directory = service.GetExecutableDirectory(); + + // Assert + directory.Should().NotBeNullOrWhiteSpace(); + Directory.Exists(directory).Should().BeTrue(); + } + + [Fact] + public void TryAcquireSingleInstanceReturnsAcquiredGuardForUnusedName() + { + // Arrange + var service = new WindowsLauncherHostEnvironmentService(); + string instanceName = CreateInstanceName(); + + // Act + using ILauncherSingleInstanceGuard guard = service.TryAcquireSingleInstance(instanceName, TimeSpan.Zero); + + // Assert + guard.IsAcquired.Should().BeTrue(); + } + + [Fact] + public void TryAcquireSingleInstanceReturnsAcquiredGuardWhenNameIsReleasedBeforeRetry() + { + // Arrange + var service = new WindowsLauncherHostEnvironmentService(); + string instanceName = CreateInstanceName(); + using ManualResetEventSlim mutexAcquired = new(); + Exception? ownerException = null; + Thread ownerThread = new(() => + { + try + { + using Mutex owner = new(initiallyOwned: true, instanceName, out _); + mutexAcquired.Set(); + Thread.Sleep(TimeSpan.FromMilliseconds(25)); + owner.ReleaseMutex(); + } + catch (Exception exception) + { + ownerException = exception; + mutexAcquired.Set(); + } + }); + + ownerThread.Start(); + mutexAcquired.Wait(TimeSpan.FromSeconds(5)).Should().BeTrue(); + + // Act + using ILauncherSingleInstanceGuard guard = service.TryAcquireSingleInstance( + instanceName, + TimeSpan.FromMilliseconds(100)); + + ownerThread.Join(); + + // Assert + ownerException.Should().BeNull(); + guard.IsAcquired.Should().BeTrue(); + } + + [Fact] + public void TryAcquireSingleInstanceReturnsRejectedGuardWhenNameIsAlreadyOwned() + { + // Arrange + var service = new WindowsLauncherHostEnvironmentService(); + string instanceName = CreateInstanceName(); + using ILauncherSingleInstanceGuard firstGuard = service.TryAcquireSingleInstance(instanceName, TimeSpan.Zero); + + // Act + using ILauncherSingleInstanceGuard secondGuard = service.TryAcquireSingleInstance(instanceName, TimeSpan.Zero); + + // Assert + firstGuard.IsAcquired.Should().BeTrue(); + secondGuard.IsAcquired.Should().BeFalse(); + } + + [Fact] + public void TryAcquireSingleInstanceThrowsForMissingName() + { + // Arrange + var service = new WindowsLauncherHostEnvironmentService(); + + // Act + Action act = () => service.TryAcquireSingleInstance(" ", TimeSpan.Zero); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void TryAcquireSingleInstanceThrowsForNegativeRetryDelay() + { + // Arrange + var service = new WindowsLauncherHostEnvironmentService(); + + // Act + Action act = () => service.TryAcquireSingleInstance(CreateInstanceName(), TimeSpan.FromMilliseconds(-1)); + + // Assert + act.Should().Throw().WithParameterName("retryDelay"); + } + + [Fact] + public void IsProtectedProgramFilesDirectoryThrowsForMissingDirectory() + { + // Arrange + var service = new WindowsLauncherHostEnvironmentService(NullLogger.Instance); + + // Act + Action act = () => service.IsProtectedProgramFilesDirectory(" "); + + // Assert + act.Should().Throw().WithParameterName("directory"); + } + + [Fact] + public void IsProtectedProgramFilesDirectoryReturnsFalseForTemporaryDirectory() + { + // Arrange + var service = new WindowsLauncherHostEnvironmentService(); + + // Act + bool result = service.IsProtectedProgramFilesDirectory(Path.GetTempPath()); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void SetCurrentDirectoryThrowsForMissingDirectory() + { + // Arrange + var service = new WindowsLauncherHostEnvironmentService(); + + // Act + Action act = () => service.SetCurrentDirectory(" "); + + // Assert + act.Should().Throw().WithParameterName("directory"); + } + + private static string CreateInstanceName() + { + return "GenLauncherGO.Tests." + Guid.NewGuid().ToString("N"); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Clients/HttpDownloadFileMetadataReaderTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/HttpDownloadFileMetadataReaderTests.cs new file mode 100644 index 00000000..bb2558ef --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/HttpDownloadFileMetadataReaderTests.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Clients; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Clients; + +public sealed class HttpDownloadFileMetadataReaderTests +{ + [Fact] + public async Task ReadMetadataAsync_UsesHeadContentDispositionFileNameStarAsync() + { + // Arrange + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse( + HttpStatusCode.OK, + contentDisposition: "attachment; filename*=UTF-8''Folder%2FPackage.big", + contentLength: 123)); + HttpDownloadFileMetadataReader reader = CreateReader(handler); + Uri uri = new("https://example.test/packages/package.big"); + + // Act + DownloadFileMetadata metadata = await reader.ReadMetadataAsync(uri, CancellationToken.None); + + // Assert + metadata.DownloadUri.Should().Be(uri); + metadata.FileName.Should().Be("FolderPackage.big"); + metadata.TotalBytes.Should().Be(123); + handler.Methods.Should().Equal(HttpMethod.Head); + } + + [Fact] + public async Task ReadMetadataAsync_FallsBackToGetWhenHeadIsNotAllowedAsync() + { + // Arrange + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.MethodNotAllowed)); + handler.Enqueue(_ => CreateResponse( + HttpStatusCode.OK, + contentDisposition: "attachment; filename=\"Package.zip\"")); + HttpDownloadFileMetadataReader reader = CreateReader(handler); + + // Act + DownloadFileMetadata metadata = await reader.ReadMetadataAsync( + new Uri("https://example.test/package.zip"), + CancellationToken.None); + + // Assert + metadata.FileName.Should().Be("Package.zip"); + handler.Methods.Should().Equal(HttpMethod.Head, HttpMethod.Get); + } + + [Fact] + public async Task ReadMetadataAsync_ThrowsWhenNeitherRequestReturnsFileNameAsync() + { + // Arrange + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK)); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK)); + HttpDownloadFileMetadataReader reader = CreateReader(handler); + + // Act + Func act = () => reader.ReadMetadataAsync( + new Uri("https://example.test/package"), + CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("Download link is incorrect, please contact modification creator and try again later."); + handler.Methods.Should().Equal(HttpMethod.Head, HttpMethod.Get); + } + + [Fact] + public async Task ReadMetadataAsync_ThrowsWhenSanitizedFileNameIsEmptyAsync() + { + // Arrange + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse( + HttpStatusCode.OK, + contentDisposition: "attachment; filename=\"\\\\\"")); + HttpDownloadFileMetadataReader reader = CreateReader(handler); + + // Act + Func act = () => reader.ReadMetadataAsync( + new Uri("https://example.test/package"), + CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("Download link is incorrect, please contact modification creator and try again later."); + } + + private static HttpDownloadFileMetadataReader CreateReader(QueueHttpMessageHandler handler) + { + return new HttpDownloadFileMetadataReader(new HttpClient(handler)); + } + + private static HttpResponseMessage CreateResponse( + HttpStatusCode statusCode, + string? contentDisposition = null, + long? contentLength = null) + { + HttpResponseMessage response = new(statusCode) + { + Content = new ByteArrayContent(Array.Empty()), + }; + if (contentDisposition is not null) + { + response.Content.Headers.ContentDisposition = ContentDispositionHeaderValue.Parse(contentDisposition); + } + + response.Content.Headers.ContentLength = contentLength; + return response; + } + + private sealed class QueueHttpMessageHandler : HttpMessageHandler + { + private readonly Queue> _responses = new(); + + public List Methods { get; } = new(); + + public void Enqueue(Func responseFactory) + { + _responses.Enqueue(responseFactory); + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Methods.Add(request.Method); + return Task.FromResult(_responses.Dequeue()(request)); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Clients/MinioClientFactoryTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/MinioClientFactoryTests.cs new file mode 100644 index 00000000..2d15f0b4 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/MinioClientFactoryTests.cs @@ -0,0 +1,42 @@ +using System; +using Minio; +using Subject = GenLauncherGO.Infrastructure.Updating.Clients.MinioClientFactory; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Clients; + +public sealed class MinioClientFactoryTests +{ + [Theory] + [InlineData("Endpoint")] + [InlineData("AccessKey")] + [InlineData("SecretKey")] + public void CreateThrowsWhenRequiredValueIsMissing(string missingValue) + { + // Act + Action act = () => Subject.Create( + missingValue == "Endpoint" ? " " : "s3.example.test", + missingValue == "AccessKey" ? " " : "access", + missingValue == "SecretKey" ? " " : "secret"); + + // Assert + act.Should().Throw(); + } + + [Theory] + [InlineData("s3.example.test")] + [InlineData("http://s3.example.test:9000/path")] + [InlineData("https://s3.example.test")] + [InlineData("s3.example.test:443")] + public void CreateBuildsClientForSupportedEndpointForms(string endpoint) + { + // Act + IMinioClient client = Subject.Create( + endpoint, + "access", + "secret", + useSsl: false); + + // Assert + client.Should().NotBeNull(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Clients/MinioS3ObjectManifestReaderTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/MinioS3ObjectManifestReaderTests.cs new file mode 100644 index 00000000..0837c4fc --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/MinioS3ObjectManifestReaderTests.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Clients; +using GenLauncherGO.Infrastructure.Updating.Models; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Clients; + +public sealed class MinioS3ObjectManifestReaderTests +{ + [Fact] + public void ConstructorThrowsWhenLoggerIsNull() + { + // Act + Action act = () => new MinioS3ObjectManifestReader(null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void ConstructorThrowsWhenListingAdapterIsNull() + { + // Act + Action act = () => new MinioS3ObjectManifestReader( + NullLogger.Instance, + null!); + + // Assert + act.Should().Throw().WithParameterName("listObjects"); + } + + [Fact] + public async Task ReadManifestAsyncThrowsWhenRequestIsNullAsync() + { + // Arrange + MinioS3ObjectManifestReader reader = CreateReader(); + + // Act + Func act = () => reader.ReadManifestAsync(null!, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync(); + } + + [Theory] + [InlineData("Endpoint")] + [InlineData("BucketName")] + [InlineData("Prefix")] + [InlineData("AccessKey")] + [InlineData("SecretKey")] + public async Task ReadManifestAsyncThrowsWhenRequiredRequestValueIsMissingAsync(string missingField) + { + // Arrange + MinioS3ObjectManifestReader reader = CreateReader(); + S3ObjectManifestRequest request = new( + missingField == "Endpoint" ? " " : "s3.example.test", + missingField == "BucketName" ? " " : "mods", + missingField == "Prefix" ? " " : "ShockWave/1.2", + missingField == "AccessKey" ? " " : "access", + missingField == "SecretKey" ? " " : "secret"); + + // Act + Func act = () => reader.ReadManifestAsync(request, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ReadManifestAsyncReturnsPrefixRelativeEntriesAndRestoresCultureAsync() + { + // Arrange + CultureInfo previousCulture = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR"); + CultureInfo? listingCulture = null; + S3ObjectManifestRequest? receivedRequest = null; + CancellationTokenSource cancellationTokenSource = new(); + S3ObjectManifestRequest request = CreateRequest(prefix: "ShockWave/1.2"); + MinioS3ObjectManifestReader reader = new( + NullLogger.Instance, + (manifestRequest, cancellationToken) => + { + receivedRequest = manifestRequest; + cancellationToken.Should().Be(cancellationTokenSource.Token); + listingCulture = CultureInfo.CurrentCulture; + return EnumerateObjectsAsync( + new MinioS3ObjectManifestReader.S3ObjectManifestItem( + "ShockWave/1.2/files/launcher.big", + " \"ABC123\" ", + 42), + new MinioS3ObjectManifestReader.S3ObjectManifestItem( + "outside-prefix.big", + "DEF456", + 7)); + }); + + try + { + // Act + IReadOnlyList entries = await reader.ReadManifestAsync( + request, + cancellationTokenSource.Token); + + // Assert + entries.Should().Equal( + new RemoteFileManifestEntry("files/launcher.big", "ABC123", 42), + new RemoteFileManifestEntry("outside-prefix.big", "DEF456", 7)); + receivedRequest.Should().BeSameAs(request); + listingCulture.Should().NotBeNull(); + listingCulture!.Name.Should().Be("en-US"); + listingCulture.DateTimeFormat.Calendar.Should().BeOfType(); + CultureInfo.CurrentCulture.Name.Should().Be("fr-FR"); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + cancellationTokenSource.Dispose(); + } + } + + [Fact] + public async Task ReadManifestAsyncRestoresCultureWhenListingFailsAsync() + { + // Arrange + CultureInfo previousCulture = CultureInfo.CurrentCulture; + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo("fr-FR"); + MinioS3ObjectManifestReader reader = new( + NullLogger.Instance, + (_, _) => throw new InvalidOperationException("Listing failed.")); + + try + { + // Act + Func act = () => reader.ReadManifestAsync(CreateRequest(), CancellationToken.None); + + // Assert + await act.Should().ThrowAsync(); + CultureInfo.CurrentCulture.Name.Should().Be("fr-FR"); + } + finally + { + CultureInfo.CurrentCulture = previousCulture; + } + } + + private static MinioS3ObjectManifestReader CreateReader() + { + return new MinioS3ObjectManifestReader(NullLogger.Instance); + } + + private static S3ObjectManifestRequest CreateRequest(string prefix = "ShockWave") + { + return new S3ObjectManifestRequest( + "s3.example.test", + "mods", + prefix, + "access", + "secret"); + } + + private static async IAsyncEnumerable EnumerateObjectsAsync( + params MinioS3ObjectManifestReader.S3ObjectManifestItem[] items) + { + await Task.Yield(); + + foreach (MinioS3ObjectManifestReader.S3ObjectManifestItem item in items) + { + yield return item; + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Clients/ResumableHttpFileDownloaderTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/ResumableHttpFileDownloaderTests.cs new file mode 100644 index 00000000..a98beede --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/ResumableHttpFileDownloaderTests.cs @@ -0,0 +1,533 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Clients; +using GenLauncherGO.Infrastructure.Updating.Options; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Clients; + +public sealed class ResumableHttpFileDownloaderTests +{ + [Theory] + [InlineData("BufferSize")] + [InlineData("MaxAttempts")] + [InlineData("IdleTimeout")] + [InlineData("ProgressReportInterval")] + public void ConstructorThrowsForInvalidOptions(string invalidOption) + { + // Arrange + ResumableHttpDownloadOptions options = invalidOption switch + { + "BufferSize" => new ResumableHttpDownloadOptions { BufferSize = 0 }, + "MaxAttempts" => new ResumableHttpDownloadOptions { MaxAttempts = 0 }, + "IdleTimeout" => new ResumableHttpDownloadOptions { IdleTimeout = TimeSpan.Zero }, + "ProgressReportInterval" => new ResumableHttpDownloadOptions { ProgressReportInterval = TimeSpan.Zero }, + _ => throw new ArgumentOutOfRangeException(nameof(invalidOption), invalidOption, null), + }; + + // Act + Action act = () => new ResumableHttpFileDownloader(options: options); + + // Assert + act.Should().Throw().WithParameterName("options"); + } + + [Fact] + public async Task DownloadFileAsyncThrowsForNullRequestAsync() + { + // Arrange + ResumableHttpFileDownloader downloader = CreateDownloader(new QueueHttpMessageHandler()); + + // Act + Func act = () => downloader.DownloadFileAsync(null!, null, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync().WithParameterName("request"); + } + + [Theory] + [InlineData("RelativeUri")] + [InlineData("UnsupportedScheme")] + [InlineData("MissingDestination")] + public async Task DownloadFileAsyncThrowsForInvalidRequestAsync(string invalidRequest) + { + // Arrange + ResumableHttpFileDownloader downloader = CreateDownloader(new QueueHttpMessageHandler()); + DownloadFileRequest request = invalidRequest switch + { + "RelativeUri" => new DownloadFileRequest(new Uri("mod.zip", UriKind.Relative), "mod.zip"), + "UnsupportedScheme" => new DownloadFileRequest(new Uri("ftp://example.test/mod.zip"), "mod.zip"), + "MissingDestination" => new DownloadFileRequest(new Uri("https://example.test/mod.zip"), " "), + _ => throw new ArgumentOutOfRangeException(nameof(invalidRequest), invalidRequest, null), + }; + string expectedParameterName = invalidRequest == "MissingDestination" + ? "request.DestinationFilePath" + : "request"; + + // Act + Func act = () => downloader.DownloadFileAsync(request, null, CancellationToken.None); + + // Assert + await act.Should().ThrowAsync().WithParameterName(expectedParameterName); + } + + [Fact] + public async Task DownloadFileAsync_WritesResponseBodyToDestinationAsync() + { + // Arrange + byte[] payload = Encoding.UTF8.GetBytes("download-content"); + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.zip"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, payload)); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + RecordingProgress progress = new(); + + // Act + DownloadFileResult result = await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.zip"), destinationFilePath), + progress, + CancellationToken.None); + + // Assert + File.ReadAllBytes(destinationFilePath).Should().Equal(payload); + result.FilePath.Should().Be(destinationFilePath); + result.BytesWritten.Should().Be(payload.Length); + result.Resumed.Should().BeFalse(); + progress.Reports.Should().Contain(report => report.BytesDownloaded == payload.Length); + } + + [Fact] + public async Task DownloadFileAsync_ResumesExistingPartialFileWithRangeRequestAsync() + { + // Arrange + byte[] partialPayload = Encoding.UTF8.GetBytes("abc"); + byte[] remainingPayload = Encoding.UTF8.GetBytes("def"); + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + await File.WriteAllBytesAsync(destinationFilePath, partialPayload); + + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => + { + HttpResponseMessage response = CreateResponse(HttpStatusCode.PartialContent, remainingPayload); + response.Content.Headers.ContentRange = new ContentRangeHeaderValue(3, 5, 6); + return response; + }); + + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + // Act + DownloadFileResult result = await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath, ExpectedBytes: 6), + null, + CancellationToken.None); + + // Assert + handler.RangeHeaders.Should().ContainSingle().Which.Should().Be("bytes=3-"); + File.ReadAllText(destinationFilePath).Should().Be("abcdef"); + result.BytesWritten.Should().Be(6); + result.Resumed.Should().BeTrue(); + } + + [Fact] + public async Task DownloadFileAsync_RestartsWhenServerIgnoresRangeRequestAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.zip"); + await File.WriteAllTextAsync(destinationFilePath, "partial"); + + byte[] fullPayload = Encoding.UTF8.GetBytes("fresh"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, fullPayload)); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + // Act + DownloadFileResult result = await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.zip"), destinationFilePath), + null, + CancellationToken.None); + + // Assert + handler.RangeHeaders.Should().ContainSingle().Which.Should().Be("bytes=7-"); + File.ReadAllText(destinationFilePath).Should().Be("fresh"); + result.BytesWritten.Should().Be(fullPayload.Length); + result.Resumed.Should().BeFalse(); + } + + [Fact] + public async Task DownloadFileAsync_RestartsWhenServerReturnsUnexpectedContentRangeAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + await File.WriteAllTextAsync(destinationFilePath, "abc"); + + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => + { + HttpResponseMessage response = CreateResponse(HttpStatusCode.PartialContent, Encoding.UTF8.GetBytes("xyz")); + response.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 2, 6); + return response; + }); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, Encoding.UTF8.GetBytes("abcdef"))); + + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + // Act + DownloadFileResult result = await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath, ExpectedBytes: 6), + null, + CancellationToken.None); + + // Assert + handler.RangeHeaders.Should().Equal("bytes=3-", null); + File.ReadAllText(destinationFilePath).Should().Be("abcdef"); + result.BytesWritten.Should().Be(6); + result.Resumed.Should().BeFalse(); + } + + [Fact] + public async Task DownloadFileAsyncReturnsExistingCompleteFileWithoutRequestAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + await File.WriteAllTextAsync(destinationFilePath, "ready"); + QueueHttpMessageHandler handler = new(); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + RecordingProgress progress = new(); + + // Act + DownloadFileResult result = await downloader.DownloadFileAsync( + new DownloadFileRequest( + new Uri("https://example.test/mod.big"), + destinationFilePath, + ExpectedBytes: 5), + progress, + CancellationToken.None); + + // Assert + result.BytesWritten.Should().Be(5); + result.Resumed.Should().BeTrue(); + handler.RangeHeaders.Should().BeEmpty(); + progress.Reports.Should().ContainSingle(report => + report.TotalBytes == 5 && + report.BytesDownloaded == 5 && + report.ProgressPercentage == 100); + } + + [Fact] + public async Task DownloadFileAsyncRestartsWhenExistingFileIsLargerThanExpectedAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + await File.WriteAllTextAsync(destinationFilePath, "too-large"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, Encoding.UTF8.GetBytes("fresh"))); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + // Act + DownloadFileResult result = await downloader.DownloadFileAsync( + new DownloadFileRequest( + new Uri("https://example.test/mod.big"), + destinationFilePath, + ExpectedBytes: 5), + null, + CancellationToken.None); + + // Assert + handler.RangeHeaders.Should().ContainSingle().Which.Should().BeNull(); + File.ReadAllText(destinationFilePath).Should().Be("fresh"); + result.Resumed.Should().BeFalse(); + } + + [Fact] + public async Task DownloadFileAsync_ReportsProgressWhileContentIsDownloadingAsync() + { + // Arrange + byte[] payload = Encoding.UTF8.GetBytes("abcdef"); + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => + { + HttpResponseMessage response = new(HttpStatusCode.OK) + { + Content = new StreamContent(new DelayedChunkStream(payload, 2, TimeSpan.FromMilliseconds(5))), + }; + response.Content.Headers.ContentLength = payload.Length; + return response; + }); + ResumableHttpFileDownloader downloader = CreateDownloader( + handler, + options: new ResumableHttpDownloadOptions + { + BufferSize = 4, + InitialRetryDelay = TimeSpan.FromMilliseconds(1), + IdleTimeout = TimeSpan.FromSeconds(5), + MaxAttempts = 2, + ProgressReportInterval = TimeSpan.FromMilliseconds(1), + }); + RecordingProgress progress = new(); + + // Act + await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath), + progress, + CancellationToken.None); + + // Assert + long[] reportedBytes = progress.Reports.Select(report => report.BytesDownloaded).ToArray(); + reportedBytes.Should().StartWith(0); + reportedBytes.Should().Contain(value => value > 0 && value < payload.Length); + reportedBytes.Should().EndWith(payload.Length); + reportedBytes.Should().BeInAscendingOrder(); + } + + [Fact] + public async Task DownloadFileAsyncThrowsAfterFinalRetriableFailureAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => throw new HttpRequestException("offline")); + handler.Enqueue(_ => throw new HttpRequestException("still offline")); + ResumableHttpFileDownloader downloader = CreateDownloader( + handler, + options: new ResumableHttpDownloadOptions + { + BufferSize = 4, + InitialRetryDelay = TimeSpan.FromMilliseconds(1), + IdleTimeout = TimeSpan.FromSeconds(5), + MaxAttempts = 2, + ProgressReportInterval = TimeSpan.FromMilliseconds(1), + }); + + // Act + Func act = () => downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath), + null, + CancellationToken.None); + + // Assert + IOException exception = (await act.Should().ThrowAsync() + .WithMessage("Download failed after 2 attempts.")).Which; + exception.InnerException.Should().BeOfType(); + handler.RangeHeaders.Should().HaveCount(2); + } + + [Fact] + public async Task DownloadFileAsyncThrowsWhenDownloadedBytesDoNotMatchExpectedBytesAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, Encoding.UTF8.GetBytes("abc"))); + ResumableHttpFileDownloader downloader = CreateDownloader( + handler, + options: new ResumableHttpDownloadOptions + { + BufferSize = 4, + InitialRetryDelay = TimeSpan.FromMilliseconds(1), + IdleTimeout = TimeSpan.FromSeconds(5), + MaxAttempts = 1, + ProgressReportInterval = TimeSpan.FromMilliseconds(1), + }); + + // Act + Func act = () => downloader.DownloadFileAsync( + new DownloadFileRequest( + new Uri("https://example.test/mod.big"), + destinationFilePath, + ExpectedBytes: 6), + null, + CancellationToken.None); + + // Assert + IOException exception = (await act.Should().ThrowAsync() + .WithMessage("Download failed after 1 attempts.")).Which; + exception.InnerException.Should().BeOfType() + .Which.Message.Should().Be("Downloaded 3 bytes, but expected 6 bytes."); + } + + [Fact] + public async Task DownloadFileAsyncThrowsWhenTransferStallsAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(new DelayedChunkStream( + Encoding.UTF8.GetBytes("abc"), + 1, + TimeSpan.FromMilliseconds(100))), + }); + ResumableHttpFileDownloader downloader = CreateDownloader( + handler, + options: new ResumableHttpDownloadOptions + { + BufferSize = 4, + InitialRetryDelay = TimeSpan.FromMilliseconds(1), + IdleTimeout = TimeSpan.FromMilliseconds(10), + MaxAttempts = 1, + ProgressReportInterval = TimeSpan.FromMilliseconds(1), + }); + + // Act + Func act = () => downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath), + null, + CancellationToken.None); + + // Assert + IOException exception = (await act.Should().ThrowAsync() + .WithMessage("Download failed after 1 attempts.")).Which; + exception.InnerException.Should().BeOfType(); + } + + private static ResumableHttpFileDownloader CreateDownloader( + QueueHttpMessageHandler handler, + ResumableHttpDownloadOptions? options = null) + { + HttpClient httpClient = new(handler) + { + Timeout = Timeout.InfiniteTimeSpan, + }; + + return new ResumableHttpFileDownloader( + httpClient, + options: options ?? new ResumableHttpDownloadOptions + { + BufferSize = 4, + InitialRetryDelay = TimeSpan.FromMilliseconds(1), + IdleTimeout = TimeSpan.FromSeconds(5), + ProgressReportInterval = TimeSpan.FromMilliseconds(1), + MaxAttempts = 2, + }); + } + + private static HttpResponseMessage CreateResponse(HttpStatusCode statusCode, byte[] payload) + { + return new HttpResponseMessage(statusCode) + { + Content = new ByteArrayContent(payload), + }; + } + + private sealed class QueueHttpMessageHandler : HttpMessageHandler + { + private readonly Queue> _responses = new(); + + public List RangeHeaders { get; } = new(); + + public void Enqueue(Func responseFactory) + { + _responses.Enqueue(responseFactory); + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + RangeHeaders.Add(request.Headers.Range?.ToString()); + return Task.FromResult(_responses.Dequeue()(request)); + } + } + + private sealed class DelayedChunkStream : Stream + { + private readonly byte[] _payload; + private readonly int _chunkSize; + private readonly TimeSpan _delay; + private int _position; + + public DelayedChunkStream(byte[] payload, int chunkSize, TimeSpan delay) + { + _payload = payload; + _chunkSize = chunkSize; + _delay = delay; + } + + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => _payload.Length; + + public override long Position + { + get => _position; + set => throw new NotSupportedException(); + } + + public override void Flush() + { + } + + public override int Read(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + + public override async ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + if (_position >= _payload.Length) + { + return 0; + } + + await Task.Delay(_delay, cancellationToken); + int bytesToCopy = Math.Min(Math.Min(_chunkSize, buffer.Length), _payload.Length - _position); + _payload.AsMemory(_position, bytesToCopy).CopyTo(buffer); + _position += bytesToCopy; + return bytesToCopy; + } + + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException(); + } + + public override void SetLength(long value) + { + throw new NotSupportedException(); + } + + public override void Write(byte[] buffer, int offset, int count) + { + throw new NotSupportedException(); + } + } + + private sealed class RecordingProgress : IProgress + { + private readonly List _reports = new(); + + public IReadOnlyList Reports => _reports; + + public void Report(T value) + { + _reports.Add(value); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Composition/UpdatingServiceCollectionExtensionsTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Composition/UpdatingServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..61421425 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Composition/UpdatingServiceCollectionExtensionsTests.cs @@ -0,0 +1,82 @@ +using System; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Mods.Services; +using GenLauncherGO.Core.Remote; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Remote; +using Microsoft.Extensions.DependencyInjection; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Clients; +using GenLauncherGO.Infrastructure.Updating.Composition; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Services; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Composition; + +public sealed class UpdatingServiceCollectionExtensionsTests +{ + [Fact] + public void AddGenLauncherGoUpdating_ReturnsSameServiceCollection() + { + // Arrange + var services = new ServiceCollection(); + + // Act + IServiceCollection returnedServices = services.AddGenLauncherGoUpdating(); + + // Assert + returnedServices.Should().BeSameAs(services); + } + + [Fact] + public void AddGenLauncherGoUpdating_ThrowsForNullServices() + { + // Arrange + IServiceCollection? services = null; + + // Act + Action act = () => services!.AddGenLauncherGoUpdating(); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void AddGenLauncherGoUpdating_RegistersExpectedServices() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(new LauncherContentLayout("Addons", "Patches")); + services.AddSingleton(new LauncherPaths( + "Game", + "Launcher", + "Runtime", + "Cache", + "Images", + "Mods", + "Logs", + "Temp", + "Deployment")); + + // Act + services.AddGenLauncherGoUpdating(); + using ServiceProvider provider = services.BuildServiceProvider(); + + // Assert + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService() + .Should() + .BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + provider.GetRequiredService().Should().BeOfType(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Models/S3RequestDefaultsTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Models/S3RequestDefaultsTests.cs new file mode 100644 index 00000000..03880177 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Models/S3RequestDefaultsTests.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Models; + +public sealed class S3RequestDefaultsTests +{ + [Fact] + public void S3ObjectManifestRequest_DefaultsHostOnlyEndpointToNonSsl() + { + // Arrange / Act + S3ObjectManifestRequest request = new( + "gen.insave.ovh:9000", + "mods", + "folder", + "access", + "secret"); + + // Assert + request.UseSsl.Should().BeFalse(); + } + + [Fact] + public void S3PackageUpdateRequest_DefaultsHostOnlyEndpointToNonSsl() + { + // Arrange / Act + S3PackageUpdateRequest request = new( + new[] { new RemoteFileManifestEntry("file.big", "hash", 1) }, + "gen.insave.ovh:9000", + "mods", + "folder", + "access", + "secret", + "temp", + "installed", + null, + new HashSet(StringComparer.OrdinalIgnoreCase)); + + // Assert + request.UseSsl.Should().BeFalse(); + } + + [Fact] + public void CreateManifestRequest_UsesPublicCatalogKeysWhenMetadataKeysAreMissing() + { + // Arrange + ModificationVersion version = new() + { + S3HostLink = "gen.insave.ovh:9000", + S3BucketName = "mods", + S3FolderName = "folder", + }; + + // Act + S3ObjectManifestRequest request = S3CatalogDefaults.CreateManifestRequest(version); + + // Assert + request.AccessKey.Should().Be(S3CatalogDefaults.PublicAccessKey); + request.SecretKey.Should().Be(S3CatalogDefaults.PublicSecretKey); + } + + [Fact] + public void CreateManifestRequest_PreservesExplicitMetadataKeys() + { + // Arrange + ModificationVersion version = new() + { + S3HostLink = "gen.insave.ovh:9000", + S3BucketName = "mods", + S3FolderName = "folder", + S3HostPublicKey = "custom-access", + S3HostSecretKey = "custom-secret", + }; + + // Act + S3ObjectManifestRequest request = S3CatalogDefaults.CreateManifestRequest(version); + + // Assert + request.AccessKey.Should().Be("custom-access"); + request.SecretKey.Should().Be("custom-secret"); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Services/Md5FileHashServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Services/Md5FileHashServiceTests.cs new file mode 100644 index 00000000..66d05753 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Services/Md5FileHashServiceTests.cs @@ -0,0 +1,40 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Updating.Services; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Services; + +public sealed class Md5FileHashServiceTests +{ + [Fact] + public async Task ComputeMd5HashAsync_ReturnsUppercaseMd5HashAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string filePath = Path.Combine(testDirectory.Path, "payload.txt"); + await File.WriteAllTextAsync(filePath, "abc"); + var service = new Md5FileHashService(); + + // Act + string hash = await service.ComputeMd5HashAsync(filePath, CancellationToken.None); + + // Assert + hash.Should().Be("900150983CD24FB0D6963F7D28E17F72"); + } + + [Fact] + public async Task ComputeMd5HashAsync_ThrowsForMissingPathAsync() + { + // Arrange + var service = new Md5FileHashService(); + + // Act + Func act = () => service.ComputeMd5HashAsync(" ", CancellationToken.None); + + // Assert + await act.Should().ThrowAsync(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Services/PackageDownloadOperationFactoryTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Services/PackageDownloadOperationFactoryTests.cs new file mode 100644 index 00000000..77f3c969 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Services/PackageDownloadOperationFactoryTests.cs @@ -0,0 +1,115 @@ +using System; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Services; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Services; + +public sealed class PackageDownloadOperationFactoryTests +{ + [Fact] + public void Create_ReturnsSingleFileOperationWhenS3MetadataIsMissing() + { + // Arrange + PackageDownloadOperationFactory factory = CreateFactory(); + ModificationVersion latestVersion = new() + { + Name = "ShockWave", + Version = "1.2", + SimpleDownloadLink = "https://example.test/ShockWave.zip", + }; + + // Act + using IPackageDownloadOperation operation = factory.Create(CreateRequest(latestVersion)); + + // Assert + operation.Should().BeOfType(); + } + + [Fact] + public void Create_ReturnsSingleFileOperationWhenForced() + { + // Arrange + PackageDownloadOperationFactory factory = CreateFactory(); + ModificationVersion latestVersion = CreateS3Version(); + + // Act + using IPackageDownloadOperation operation = factory.Create(CreateRequest(latestVersion), true); + + // Assert + operation.Should().BeOfType(); + } + + [Fact] + public void Create_ReturnsS3OperationWhenS3MetadataIsComplete() + { + // Arrange + PackageDownloadOperationFactory factory = CreateFactory(); + ModificationVersion latestVersion = CreateS3Version(); + + // Act + using IPackageDownloadOperation operation = factory.Create(CreateRequest(latestVersion)); + + // Assert + operation.Should().BeOfType(); + } + + [Fact] + public void Create_ThrowsForMissingRequest() + { + // Arrange + PackageDownloadOperationFactory factory = CreateFactory(); + + // Act + Action act = () => factory.Create(null!); + + // Assert + act.Should().Throw(); + } + + private static PackageDownloadOperationFactory CreateFactory() + { + return new PackageDownloadOperationFactory( + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + Substitute.For(), + new LauncherPaths( + @"C:\Game", + @"C:\Launcher", + @"C:\Launcher\Runtime", + @"C:\Launcher\Cache", + @"C:\Launcher\Cache\Images", + @"C:\Launcher\Mods", + @"C:\Launcher\Logs", + @"C:\Launcher\Temp", + @"C:\Launcher\Deployment"), + new LauncherContentLayout("Addons", "Patches")); + } + + private static ModificationPackageDownloadRequest CreateRequest(ModificationVersion latestVersion) + { + GameModification modification = new(); + modification.UpdateModificationData(latestVersion); + return new ModificationPackageDownloadRequest(modification, latestVersion); + } + + private static ModificationVersion CreateS3Version() + { + return new ModificationVersion + { + Name = "ShockWave", + Version = "1.2", + S3HostLink = "https://s3.example.test", + S3BucketName = "mods", + S3FolderName = "ShockWave/1.2", + ContentSourceKind = ContentSourceKind.ManagedS3, + }; + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Services/PackageDownloadOperationTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Services/PackageDownloadOperationTests.cs new file mode 100644 index 00000000..0dc9ce84 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Services/PackageDownloadOperationTests.cs @@ -0,0 +1,372 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Mods.Services; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Services; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Services; + +public sealed class PackageDownloadOperationTests +{ + [Fact] + public async Task HttpStartDownloadModificationAsync_UsesResolvedDownloadAndLauncherPathsAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + LauncherPaths paths = CreatePaths(testDirectory.Path); + ModificationVersion latestVersion = CreateSingleFileVersion("ShockWave", "1.2"); + GameModification modification = CreateModification(latestVersion); + RecordingSingleFilePackageUpdater packageUpdater = new(); + using var operation = new HttpSingleFilePackageDownloadOperation( + packageUpdater, + new LauncherContentPathResolver(), + paths, + Layout, + new ModificationPackageDownloadRequest(modification, latestVersion)); + PackageDownloadResult? doneResult = null; + operation.Done += result => doneResult = result; + + // Act + await operation.StartDownloadModificationAsync(); + + // Assert + SingleFilePackageUpdateRequest request = packageUpdater.Requests.Should().ContainSingle().Which; + request.SourceUri.Should().Be(new Uri("https://www.dropbox.com/s/package/file.zip?dl=1")); + request.InstalledFolderPath.Should().Be(Path.Combine(paths.ModsDirectory, "ShockWave", "1.2")); + request.TemporaryFolderPath.Should().Be(Path.Combine(paths.TempDirectory, "Packages", "ShockWave", "1.2")); + Directory.Exists(request.TemporaryFolderPath).Should().BeTrue(); + operation.GetResult().Should().BeEquivalentTo(new PackageDownloadResult()); + doneResult.Should().BeEquivalentTo(new PackageDownloadResult()); + } + + [Fact] + public async Task HttpStartDownloadModificationAsync_ReportsInnerExceptionMessageAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + LauncherPaths paths = CreatePaths(testDirectory.Path); + ModificationVersion latestVersion = CreateSingleFileVersion("ShockWave", "1.2"); + RecordingSingleFilePackageUpdater packageUpdater = new() + { + ExceptionToThrow = new InvalidOperationException( + "outer failure", + new InvalidOperationException("inner failure")), + }; + using var operation = new HttpSingleFilePackageDownloadOperation( + packageUpdater, + new LauncherContentPathResolver(), + paths, + Layout, + new ModificationPackageDownloadRequest(CreateModification(latestVersion), latestVersion)); + PackageDownloadResult? doneResult = null; + operation.Done += result => doneResult = result; + + // Act + await operation.StartDownloadModificationAsync(); + + // Assert + PackageDownloadResult result = operation.GetResult(); + result.Crashed.Should().BeTrue(); + result.Message.Should().Be("inner failure"); + doneResult.Should().BeEquivalentTo(result); + } + + [Fact] + public async Task HttpCancelDownload_CancelsActiveUpdaterAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + LauncherPaths paths = CreatePaths(testDirectory.Path); + ModificationVersion latestVersion = CreateSingleFileVersion("ShockWave", "1.2"); + BlockingSingleFilePackageUpdater packageUpdater = new(); + using var operation = new HttpSingleFilePackageDownloadOperation( + packageUpdater, + new LauncherContentPathResolver(), + paths, + Layout, + new ModificationPackageDownloadRequest(CreateModification(latestVersion), latestVersion)); + + // Act + Task downloadTask = operation.StartDownloadModificationAsync(); + await packageUpdater.Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + operation.CancelDownload(); + await downloadTask.WaitAsync(TimeSpan.FromSeconds(5)); + + // Assert + operation.GetResult().Canceled.Should().BeTrue(); + operation.GetResult().Message.Should().Be("Download Canceled"); + } + + [Fact] + public void S3GetPackageDownloadReadiness_ReturnsTimeSyncErrorWhenClockIsOutOfSync() + { + // Arrange + using TestDirectory testDirectory = new(); + LauncherPaths paths = CreatePaths(testDirectory.Path); + ModificationVersion latestVersion = CreateS3Version("ShockWave", "2.0"); + using var operation = new S3PackageDownloadOperation( + new RecordingS3PackageUpdater(), + new RecordingS3ObjectManifestReader(), + new StubSystemClockService { IsOutOfSync = true }, + new LauncherContentPathResolver(), + paths, + Layout, + new ModificationPackageDownloadRequest(CreateModification(latestVersion), latestVersion)); + + // Act + PackageDownloadReadiness readiness = operation.GetPackageDownloadReadiness(); + + // Assert + readiness.ReadyToDownload.Should().BeFalse(); + readiness.Error.Should().Be(PackageDownloadReadinessError.TimeOutOfSync); + } + + [Fact] + public async Task S3StartDownloadModificationAsync_ReadsManifestAndBuildsUpdateRequestAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + LauncherPaths paths = CreatePaths(testDirectory.Path); + ModificationVersion installedVersion = CreateS3Version("ShockWave", "1.0"); + installedVersion.Installed = true; + ModificationVersion latestVersion = CreateS3Version("ShockWave", "2.0"); + GameModification modification = CreateModification(installedVersion, latestVersion); + RemoteFileManifestEntry[] manifestEntries = + { + new RemoteFileManifestEntry("Data/file.big", "0123456789ABCDEF0123456789ABCDEF", 10), + }; + RecordingS3ObjectManifestReader manifestReader = new(manifestEntries); + RecordingS3PackageUpdater packageUpdater = new(); + using var operation = new S3PackageDownloadOperation( + packageUpdater, + manifestReader, + new StubSystemClockService(), + new LauncherContentPathResolver(), + paths, + Layout, + new ModificationPackageDownloadRequest(modification, latestVersion)); + PackageDownloadResult? doneResult = null; + operation.Done += result => doneResult = result; + + // Act + await operation.StartDownloadModificationAsync(); + + // Assert + S3ObjectManifestRequest manifestRequest = manifestReader.Requests.Should().ContainSingle().Which; + manifestRequest.Endpoint.Should().Be("https://s3.example.test"); + manifestRequest.BucketName.Should().Be("mods"); + manifestRequest.Prefix.Should().Be("ShockWave/2.0"); + manifestRequest.AccessKey.Should().Be("access-key"); + manifestRequest.SecretKey.Should().Be("secret-key"); + + S3PackageUpdateRequest updateRequest = packageUpdater.Requests.Should().ContainSingle().Which; + updateRequest.Files.Should().Equal(manifestEntries); + updateRequest.InstalledFolderPath.Should().Be(Path.Combine(paths.ModsDirectory, "ShockWave", "2.0")); + updateRequest.LatestInstalledFolderPath.Should().Be(Path.Combine(paths.ModsDirectory, "ShockWave", "1.0")); + updateRequest.TemporaryFolderPath.Should().Be(Path.Combine(paths.TempDirectory, "Packages", "ShockWave", "2.0")); + updateRequest.HashCheckedExtensions.Should().Contain(".big"); + updateRequest.HashCheckedExtensions.Should().Contain(".gib"); + operation.GetResult().Should().BeEquivalentTo(new PackageDownloadResult()); + doneResult.Should().BeEquivalentTo(new PackageDownloadResult()); + } + + [Fact] + public async Task S3StartDownloadModificationAsync_ReportsManifestFailureAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + LauncherPaths paths = CreatePaths(testDirectory.Path); + ModificationVersion latestVersion = CreateS3Version("ShockWave", "2.0"); + RecordingS3ObjectManifestReader manifestReader = new() + { + ExceptionToThrow = new InvalidOperationException("manifest unavailable"), + }; + using var operation = new S3PackageDownloadOperation( + new RecordingS3PackageUpdater(), + manifestReader, + new StubSystemClockService(), + new LauncherContentPathResolver(), + paths, + Layout, + new ModificationPackageDownloadRequest(CreateModification(latestVersion), latestVersion)); + + // Act + await operation.StartDownloadModificationAsync(); + + // Assert + PackageDownloadResult result = operation.GetResult(); + result.Crashed.Should().BeTrue(); + result.Message.Should().Be("manifest unavailable"); + } + + private static LauncherContentLayout Layout { get; } = new LauncherContentLayout("Addons", "Patches"); + + private static LauncherPaths CreatePaths(string root) + { + string launcherDirectory = Path.Combine(root, "GenLauncherGO"); + + return new LauncherPaths( + Path.Combine(root, "Game"), + launcherDirectory, + Path.Combine(launcherDirectory, "Runtime"), + Path.Combine(launcherDirectory, "Runtime", "Cache"), + Path.Combine(launcherDirectory, "Runtime", "Cache", "Images"), + Path.Combine(launcherDirectory, "Mods"), + Path.Combine(launcherDirectory, "Logs"), + Path.Combine(launcherDirectory, "Runtime", "Temp"), + Path.Combine(launcherDirectory, "Runtime", "Deployment")); + } + + private static ModificationVersion CreateSingleFileVersion(string name, string version) + { + return new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = name, + Version = version, + SimpleDownloadLink = "https://www.dropbox.com/s/package/file.zip?dl=0", + ContentSourceKind = ContentSourceKind.ManagedSingleFile, + }; + } + + private static ModificationVersion CreateS3Version(string name, string version) + { + return new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = name, + Version = version, + S3HostLink = "https://s3.example.test", + S3BucketName = "mods", + S3FolderName = $"{name}/{version}", + S3HostPublicKey = "access-key", + S3HostSecretKey = "secret-key", + ContentSourceKind = ContentSourceKind.ManagedS3, + }; + } + + private static GameModification CreateModification(params ModificationVersion[] versions) + { + var modification = new GameModification(); + foreach (ModificationVersion version in versions) + { + modification.UpdateModificationData(version); + } + + return modification; + } + + private sealed class RecordingSingleFilePackageUpdater : ISingleFilePackageUpdater + { + public List Requests { get; } = new(); + + public Exception? ExceptionToThrow { get; init; } + + public Task UpdateAsync( + SingleFilePackageUpdateRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + Requests.Add(request); + if (ExceptionToThrow != null) + { + throw ExceptionToThrow; + } + + return Task.CompletedTask; + } + } + + private sealed class BlockingSingleFilePackageUpdater : ISingleFilePackageUpdater + { + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public Task UpdateAsync( + SingleFilePackageUpdateRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + Started.TrySetResult(true); + return Task.Delay(TimeSpan.FromMinutes(5), cancellationToken); + } + } + + private sealed class RecordingS3PackageUpdater : IS3PackageUpdater + { + public List Requests { get; } = new(); + + public Task UpdateAsync( + S3PackageUpdateRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + Requests.Add(request); + return Task.CompletedTask; + } + + public Task RepairFilesAsync( + S3PackageFileRepairRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + } + + private sealed class RecordingS3ObjectManifestReader : IS3ObjectManifestReader + { + private readonly IReadOnlyList _files; + + public RecordingS3ObjectManifestReader() + : this(Array.Empty()) + { + } + + public RecordingS3ObjectManifestReader(IReadOnlyList files) + { + _files = files; + } + + public List Requests { get; } = new(); + + public Exception? ExceptionToThrow { get; init; } + + public Task> ReadManifestAsync( + S3ObjectManifestRequest request, + CancellationToken cancellationToken) + { + Requests.Add(request); + if (ExceptionToThrow != null) + { + throw ExceptionToThrow; + } + + return Task.FromResult(_files); + } + } + + private sealed class StubSystemClockService : ISystemClockService + { + public bool IsOutOfSync { get; init; } + + public bool IsSystemTimeOutOfSync() + { + return IsOutOfSync; + } + + public bool TrySynchronizeSystemTimeWithNetworkTime() + { + return true; + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Services/S3PackageUpdaterBehaviorTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Services/S3PackageUpdaterBehaviorTests.cs new file mode 100644 index 00000000..293e6eec --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Services/S3PackageUpdaterBehaviorTests.cs @@ -0,0 +1,357 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Options; +using GenLauncherGO.Infrastructure.Updating.Services; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Services; + +public sealed class S3PackageUpdaterBehaviorTests +{ + [Fact] + public async Task UpdateAsync_CopiesMatchingFilesFromLatestAndSkipsDownloadAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string latestPath = Path.Combine(testDirectory.Path, "latest"); + string temporaryPath = Path.Combine(testDirectory.Path, "temp"); + string installedPath = Path.Combine(testDirectory.Path, "installed"); + string latestFilePath = Path.Combine(latestPath, "Data", "readme.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(latestFilePath)!); + await File.WriteAllTextAsync(latestFilePath, "payload"); + + string hash = "0123456789ABCDEF0123456789ABCDEF"; + RecordingFileDownloader downloader = new(); + StubFileHashService hashService = new() { HashForPath = _ => hash }; + S3PackageUpdater updater = CreateUpdater(downloader, hashService); + RecordingProgress progress = new(); + + // Act + await updater.UpdateAsync( + CreateRequest( + temporaryPath, + installedPath, + latestPath, + new RemoteFileManifestEntry("Data/readme.txt", hash, (ulong)new FileInfo(latestFilePath).Length)), + progress, + CancellationToken.None); + + // Assert + downloader.Requests.Should().BeEmpty(); + File.ReadAllText(Path.Combine(installedPath, "Data", "readme.txt")).Should().Be("payload"); + progress.Reports.Should().Contain(report => report.FileName == null); + } + + [Fact] + public async Task UpdateAsync_ReportsOnlyMissingFileBytesWhenLatestFilesAreReusedAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string latestPath = Path.Combine(testDirectory.Path, "latest"); + string temporaryPath = Path.Combine(testDirectory.Path, "temp"); + string installedPath = Path.Combine(testDirectory.Path, "installed"); + string latestFilePath = Path.Combine(latestPath, "Data", "reused.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(latestFilePath)!); + await File.WriteAllBytesAsync(latestFilePath, CreatePayload(660)); + + string hash = "0123456789ABCDEF0123456789ABCDEF"; + RecordingFileDownloader downloader = new(); + StubFileHashService hashService = new() { HashForPath = _ => hash }; + S3PackageUpdater updater = CreateUpdater(downloader, hashService); + RecordingProgress progress = new(); + + // Act + await updater.UpdateAsync( + CreateRequest( + temporaryPath, + installedPath, + latestPath, + new RemoteFileManifestEntry("Data/reused.txt", hash, 660), + new RemoteFileManifestEntry("Data/missing.txt", hash, 20)), + progress, + CancellationToken.None); + + // Assert + downloader.Requests.Should().ContainSingle(); + progress.Reports.Should().ContainSingle(); + progress.Reports[0].TotalBytes.Should().Be(20); + progress.Reports[0].BytesRead.Should().Be(20); + progress.Reports[0].ProgressPercentage.Should().Be(100); + } + + [Fact] + public async Task UpdateAsync_ReportsOnlyRemainingBytesForPartialStagedDownloadAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string temporaryPath = Path.Combine(testDirectory.Path, "temp"); + string installedPath = Path.Combine(testDirectory.Path, "installed"); + string partialFilePath = Path.Combine(temporaryPath, "Data", "missing.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(partialFilePath)!); + await File.WriteAllBytesAsync(partialFilePath, CreatePayload(5)); + + string hash = "0123456789ABCDEF0123456789ABCDEF"; + RecordingFileDownloader downloader = new(); + StubFileHashService hashService = new() { HashForPath = _ => hash }; + S3PackageUpdater updater = CreateUpdater(downloader, hashService); + RecordingProgress progress = new(); + + // Act + await updater.UpdateAsync( + CreateRequest( + temporaryPath, + installedPath, + null, + new RemoteFileManifestEntry("Data/missing.txt", hash, 20)), + progress, + CancellationToken.None); + + // Assert + downloader.Requests.Should().ContainSingle(); + progress.Reports.Should().ContainSingle(); + progress.Reports[0].TotalBytes.Should().Be(15); + progress.Reports[0].BytesRead.Should().Be(15); + progress.Reports[0].ProgressPercentage.Should().Be(100); + } + + [Fact] + public async Task UpdateAsync_RejectsManifestPathOutsideTemporaryFolderAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + S3PackageUpdater updater = CreateUpdater(new RecordingFileDownloader(), new StubFileHashService()); + + // Act + Func act = async () => await updater.UpdateAsync( + CreateRequest( + Path.Combine(testDirectory.Path, "temp"), + Path.Combine(testDirectory.Path, "installed"), + null, + new RemoteFileManifestEntry("../escape.txt", "0123456789ABCDEF0123456789ABCDEF", 1)), + null, + CancellationToken.None); + + // Assert + await act.Should().ThrowAsync(); + File.Exists(Path.Combine(testDirectory.Path, "escape.txt")).Should().BeFalse(); + } + + [Fact] + public async Task UpdateAsync_PrunesStaleTemporaryFilesBeforeInstallingAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string temporaryRoot = Path.Combine(testDirectory.Path, "Runtime", "Temp"); + string packagesPath = Path.Combine(temporaryRoot, "Packages"); + string temporaryPath = Path.Combine(packagesPath, "NProject Mod", "2.11"); + string installedPath = Path.Combine(testDirectory.Path, "installed"); + Directory.CreateDirectory(temporaryPath); + await File.WriteAllTextAsync(Path.Combine(temporaryPath, "stale.txt"), "stale"); + await File.WriteAllTextAsync(Path.Combine(temporaryPath, "readme.txt"), "payload"); + + string hash = "0123456789ABCDEF0123456789ABCDEF"; + S3PackageUpdater updater = CreateUpdater( + new RecordingFileDownloader(), + new StubFileHashService { HashForPath = _ => hash }); + + // Act + await updater.UpdateAsync( + CreateRequest( + temporaryPath, + installedPath, + null, + new RemoteFileManifestEntry("readme.txt", hash, 7)), + null, + CancellationToken.None); + + // Assert + File.Exists(Path.Combine(installedPath, "stale.txt")).Should().BeFalse(); + File.ReadAllText(Path.Combine(installedPath, "readme.txt")).Should().Be("payload"); + Directory.Exists(packagesPath).Should().BeFalse(); + Directory.Exists(temporaryRoot).Should().BeTrue(); + } + + [Fact] + public async Task UpdateAsync_RemovesUnsafeStagingLinkWithoutDeletingTargetAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string temporaryPath = Path.Combine(testDirectory.Path, "temp"); + string installedPath = Path.Combine(testDirectory.Path, "installed"); + string outsidePath = Path.Combine(testDirectory.Path, "outside"); + Directory.CreateDirectory(temporaryPath); + Directory.CreateDirectory(outsidePath); + await File.WriteAllTextAsync(Path.Combine(temporaryPath, "readme.txt"), "payload"); + string outsideFile = Path.Combine(outsidePath, "outside.txt"); + await File.WriteAllTextAsync(outsideFile, "outside"); + try + { + Directory.CreateSymbolicLink(Path.Combine(temporaryPath, "linked"), outsidePath); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + string hash = "0123456789ABCDEF0123456789ABCDEF"; + S3PackageUpdater updater = CreateUpdater( + new RecordingFileDownloader(), + new StubFileHashService { HashForPath = _ => hash }); + + // Act + await updater.UpdateAsync( + CreateRequest( + temporaryPath, + installedPath, + null, + new RemoteFileManifestEntry("readme.txt", hash, 7)), + null, + CancellationToken.None); + + // Assert + Directory.Exists(Path.Combine(installedPath, "linked")).Should().BeFalse(); + File.ReadAllText(outsideFile).Should().Be("outside"); + } + + [Fact] + public async Task RepairFilesAsync_DownloadsSelectedModifiedFileInPlaceAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string installedPath = Path.Combine(testDirectory.Path, "installed"); + string staleFilePath = Path.Combine(installedPath, "Data", "readme.txt"); + string keepFilePath = Path.Combine(installedPath, "Data", "keep.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(staleFilePath)!); + await File.WriteAllTextAsync(staleFilePath, "stale"); + await File.WriteAllTextAsync(keepFilePath, "keep"); + + string hash = "0123456789ABCDEF0123456789ABCDEF"; + RecordingFileDownloader downloader = new(); + StubFileHashService hashService = new() + { + HashForPath = path => File.ReadAllBytes(path).All(value => value == (byte)'x') ? hash : "BAD", + }; + S3PackageUpdater updater = CreateUpdater(downloader, hashService); + RecordingProgress progress = new(); + + // Act + await updater.RepairFilesAsync( + CreateRepairRequest( + installedPath, + new RemoteFileManifestEntry("Data/readme.txt", hash, 5)), + progress, + CancellationToken.None); + + // Assert + DownloadFileRequest request = downloader.Requests.Should().ContainSingle().Which; + request.DestinationFilePath.Should().Be(staleFilePath); + File.ReadAllBytes(staleFilePath).Should().AllBeEquivalentTo((byte)'x'); + File.ReadAllText(keepFilePath).Should().Be("keep"); + progress.Reports.Should().ContainSingle(report => + report.TotalBytes == 5 && + report.BytesRead == 5 && + report.ProgressPercentage == 100); + } + + private static S3PackageUpdater CreateUpdater( + IResumableFileDownloader downloader, + IFileHashService hashService) + { + return new S3PackageUpdater( + downloader, + hashService, + NullLogger.Instance, + new S3PackageUpdateOptions()); + } + + private static S3PackageUpdateRequest CreateRequest( + string temporaryPath, + string installedPath, + string? latestPath, + params RemoteFileManifestEntry[] files) + { + return new S3PackageUpdateRequest( + files, + "https://example.test", + "mods", + "folder", + "access", + "secret", + temporaryPath, + installedPath, + latestPath, + new HashSet(StringComparer.OrdinalIgnoreCase) { ".txt", ".big", ".gib" }); + } + + private static S3PackageFileRepairRequest CreateRepairRequest( + string installedPath, + params RemoteFileManifestEntry[] files) + { + return new S3PackageFileRepairRequest( + files, + "https://example.test", + "mods", + "folder", + "access", + "secret", + installedPath, + new HashSet(StringComparer.OrdinalIgnoreCase) { ".txt", ".big", ".gib" }); + } + + private static byte[] CreatePayload(int length) + { + byte[] payload = new byte[length]; + Array.Fill(payload, (byte)'x'); + return payload; + } + + private sealed class RecordingFileDownloader : IResumableFileDownloader + { + public ConcurrentQueue Requests { get; } = new(); + + public async Task DownloadFileAsync( + DownloadFileRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + Requests.Enqueue(request); + long length = request.ExpectedBytes.GetValueOrDefault(); + byte[] payload = CreatePayload(checked((int)length)); + Directory.CreateDirectory(Path.GetDirectoryName(request.DestinationFilePath)!); + await File.WriteAllBytesAsync(request.DestinationFilePath, payload, cancellationToken); + return new DownloadFileResult(request.DestinationFilePath, payload.Length, false); + } + } + + private sealed class StubFileHashService : IFileHashService + { + public Func HashForPath { get; init; } = _ => "0123456789ABCDEF0123456789ABCDEF"; + + public Task ComputeMd5HashAsync(string filePath, CancellationToken cancellationToken) + { + return Task.FromResult(HashForPath(filePath)); + } + } + + private sealed class RecordingProgress : IProgress + { + private readonly List _reports = new(); + + public IReadOnlyList Reports => _reports; + + public void Report(PackageUpdateProgress value) + { + _reports.Add(value); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Services/S3UpdaterTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Services/S3UpdaterTests.cs new file mode 100644 index 00000000..01407bad --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Services/S3UpdaterTests.cs @@ -0,0 +1,72 @@ +using System.IO; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Services; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Services; + +public sealed class S3PackageUpdaterTests +{ + [Fact] + public void ExistingDownloadedFileMatchesExpectedSize_ReturnsFalseWhenFileIsMissing() + { + // Arrange + using TestDirectory testDirectory = new(); + var fileInfo = new RemoteFileManifestEntry("readme.txt", "ignored", 10); + string destinationFilePath = Path.Combine(testDirectory.Path, "readme.txt"); + + // Act + bool isComplete = S3PackageUpdater.ExistingDownloadedFileMatchesExpectedSize(fileInfo, destinationFilePath); + + // Assert + isComplete.Should().BeFalse(); + } + + [Fact] + public void ExistingDownloadedFileMatchesExpectedSize_ReturnsFalseWhenSizeDiffers() + { + // Arrange + using TestDirectory testDirectory = new(); + var fileInfo = new RemoteFileManifestEntry("readme.txt", "ignored", 10); + string destinationFilePath = Path.Combine(testDirectory.Path, "readme.txt"); + File.WriteAllBytes(destinationFilePath, new byte[9]); + + // Act + bool isComplete = S3PackageUpdater.ExistingDownloadedFileMatchesExpectedSize(fileInfo, destinationFilePath); + + // Assert + isComplete.Should().BeFalse(); + } + + [Fact] + public void ExistingDownloadedFileMatchesExpectedSize_ReturnsTrueWhenSizeMatches() + { + // Arrange + using TestDirectory testDirectory = new(); + var fileInfo = new RemoteFileManifestEntry("readme.txt", "ignored", 10); + string destinationFilePath = Path.Combine(testDirectory.Path, "readme.txt"); + File.WriteAllBytes(destinationFilePath, new byte[10]); + + // Act + bool isComplete = S3PackageUpdater.ExistingDownloadedFileMatchesExpectedSize(fileInfo, destinationFilePath); + + // Assert + isComplete.Should().BeTrue(); + } + + [Fact] + public void ExistingDownloadedFileMatchesExpectedSize_UsesGibPathForBigFiles() + { + // Arrange + using TestDirectory testDirectory = new(); + var fileInfo = new RemoteFileManifestEntry("data.big", "ignored", 10); + string destinationFilePath = Path.Combine(testDirectory.Path, "data.big"); + File.WriteAllBytes(Path.ChangeExtension(destinationFilePath, ".gib"), new byte[10]); + + // Act + bool isComplete = S3PackageUpdater.ExistingDownloadedFileMatchesExpectedSize(fileInfo, destinationFilePath); + + // Assert + isComplete.Should().BeTrue(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Services/SingleFilePackageUpdaterTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Services/SingleFilePackageUpdaterTests.cs new file mode 100644 index 00000000..ede394e1 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Services/SingleFilePackageUpdaterTests.cs @@ -0,0 +1,299 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Archives; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Services; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Services; + +public sealed class SingleFilePackageUpdaterTests +{ + [Fact] + public void ConstructorThrowsWhenDependenciesAreNull() + { + // Arrange + var downloader = new WritingFileDownloader("payload"); + var metadataReader = new StubMetadataReader("readme.txt", 7); + var archiveExtractor = new ThrowingArchiveExtractor(); + + // Act + Action nullDownloader = () => new SingleFilePackageUpdater( + null!, + metadataReader, + archiveExtractor, + NullLogger.Instance); + Action nullMetadataReader = () => new SingleFilePackageUpdater( + downloader, + null!, + archiveExtractor, + NullLogger.Instance); + Action nullArchiveExtractor = () => new SingleFilePackageUpdater( + downloader, + metadataReader, + null!, + NullLogger.Instance); + Action nullLogger = () => new SingleFilePackageUpdater( + downloader, + metadataReader, + archiveExtractor, + null!); + + // Assert + nullDownloader.Should().Throw().WithParameterName("fileDownloader"); + nullMetadataReader.Should().Throw().WithParameterName("metadataReader"); + nullArchiveExtractor.Should().Throw().WithParameterName("archiveExtractor"); + nullLogger.Should().Throw().WithParameterName("logger"); + } + + [Theory] + [InlineData("Request")] + [InlineData("TemporaryFolderPath")] + [InlineData("InstalledFolderPath")] + public async Task UpdateAsyncThrowsForInvalidRequestAsync(string invalidRequest) + { + // Arrange + SingleFilePackageUpdater updater = new( + new WritingFileDownloader("payload"), + new StubMetadataReader("readme.txt", 7), + new ThrowingArchiveExtractor(), + NullLogger.Instance); + SingleFilePackageUpdateRequest? request = invalidRequest == "Request" + ? null + : new SingleFilePackageUpdateRequest( + new Uri("https://example.test/readme.txt"), + invalidRequest == "TemporaryFolderPath" ? " " : "temp", + invalidRequest == "InstalledFolderPath" ? " " : "installed"); + + // Act + Func act = () => updater.UpdateAsync(request!, null, CancellationToken.None); + + // Assert + if (invalidRequest == "Request") + { + await act.Should().ThrowAsync().WithParameterName("request"); + } + else + { + await act.Should().ThrowAsync() + .WithParameterName($"request.{invalidRequest}"); + } + } + + [Fact] + public async Task UpdateAsync_ClearsStaleTemporaryFilesBeforeInstallingAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string temporaryPath = Path.Combine(testDirectory.Path, "temp"); + string installedPath = Path.Combine(testDirectory.Path, "installed"); + Directory.CreateDirectory(temporaryPath); + await File.WriteAllTextAsync(Path.Combine(temporaryPath, "stale.txt"), "stale"); + + SingleFilePackageUpdater updater = new( + new WritingFileDownloader("payload"), + new StubMetadataReader("readme.txt", 7), + new ThrowingArchiveExtractor(), + NullLogger.Instance); + + // Act + await updater.UpdateAsync( + new SingleFilePackageUpdateRequest( + new Uri("https://example.test/readme.txt"), + temporaryPath, + installedPath), + null, + CancellationToken.None); + + // Assert + File.Exists(Path.Combine(installedPath, "stale.txt")).Should().BeFalse(); + File.ReadAllText(Path.Combine(installedPath, "readme.txt")).Should().Be("payload"); + } + + [Fact] + public async Task UpdateAsync_RemovesEmptyPackageStagingParentsAfterInstallingAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string temporaryRoot = Path.Combine(testDirectory.Path, "Runtime", "Temp"); + string packagesPath = Path.Combine(temporaryRoot, "Packages"); + string temporaryPath = Path.Combine(packagesPath, "NProject Mod", "2.11"); + string installedPath = Path.Combine(testDirectory.Path, "installed"); + + SingleFilePackageUpdater updater = new( + new WritingFileDownloader("payload"), + new StubMetadataReader("readme.txt", 7), + new ThrowingArchiveExtractor(), + NullLogger.Instance); + + // Act + await updater.UpdateAsync( + new SingleFilePackageUpdateRequest( + new Uri("https://example.test/readme.txt"), + temporaryPath, + installedPath), + null, + CancellationToken.None); + + // Assert + File.ReadAllText(Path.Combine(installedPath, "readme.txt")).Should().Be("payload"); + Directory.Exists(packagesPath).Should().BeFalse(); + Directory.Exists(temporaryRoot).Should().BeTrue(); + } + + [Fact] + public async Task UpdateAsyncExtractsArchiveDeletesDownloadedArchiveAndInstallsExtractedFilesAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string temporaryPath = Path.Combine(testDirectory.Path, "temp"); + string installedPath = Path.Combine(testDirectory.Path, "installed"); + var archiveExtractor = new RecordingArchiveExtractor("extracted.gib", "extracted"); + + SingleFilePackageUpdater updater = new( + new WritingFileDownloader("archive"), + new StubMetadataReader("package.zip", 7), + archiveExtractor, + NullLogger.Instance); + + // Act + await updater.UpdateAsync( + new SingleFilePackageUpdateRequest( + new Uri("https://example.test/package.zip"), + temporaryPath, + installedPath), + null, + CancellationToken.None); + + // Assert + File.Exists(Path.Combine(installedPath, "package.zip")).Should().BeFalse(); + File.ReadAllText(Path.Combine(installedPath, "extracted.gib")).Should().Be("extracted"); + archiveExtractor.ArchiveFileName.Should().Be("package.zip"); + archiveExtractor.ConvertBigFilesToGib.Should().BeTrue(); + } + + [Fact] + public async Task UpdateAsyncRejectsLinkedInstalledRootWithoutDeletingTargetAsync() + { + // Arrange + using TestDirectory testDirectory = new(); + string temporaryPath = Path.Combine(testDirectory.Path, "temp"); + string installedPath = Path.Combine(testDirectory.Path, "installed"); + string outsidePath = Path.Combine(testDirectory.Path, "outside"); + Directory.CreateDirectory(outsidePath); + string outsideFile = Path.Combine(outsidePath, "outside.txt"); + await File.WriteAllTextAsync(outsideFile, "outside"); + try + { + Directory.CreateSymbolicLink(installedPath, outsidePath); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return; + } + + SingleFilePackageUpdater updater = new( + new WritingFileDownloader("payload"), + new StubMetadataReader("readme.txt", 7), + new ThrowingArchiveExtractor(), + NullLogger.Instance); + + // Act + Func update = () => updater.UpdateAsync( + new SingleFilePackageUpdateRequest( + new Uri("https://example.test/readme.txt"), + temporaryPath, + installedPath), + null, + CancellationToken.None); + + // Assert + await update.Should().ThrowAsync(); + File.ReadAllText(outsideFile).Should().Be("outside"); + } + + private sealed class WritingFileDownloader : IResumableFileDownloader + { + private readonly string _contents; + + public WritingFileDownloader(string contents) + { + _contents = contents; + } + + public async Task DownloadFileAsync( + DownloadFileRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + await File.WriteAllTextAsync(request.DestinationFilePath, _contents, cancellationToken); + progress?.Report(new DownloadProgress(request.ExpectedBytes, _contents.Length, 100)); + return new DownloadFileResult(request.DestinationFilePath, _contents.Length, false); + } + } + + private sealed class StubMetadataReader : IDownloadFileMetadataReader + { + private readonly string _fileName; + private readonly long _totalBytes; + + public StubMetadataReader(string fileName, long totalBytes) + { + _fileName = fileName; + _totalBytes = totalBytes; + } + + public Task ReadMetadataAsync( + Uri downloadUri, + CancellationToken cancellationToken) + { + return Task.FromResult(new DownloadFileMetadata(downloadUri, _fileName, _totalBytes)); + } + } + + private sealed class ThrowingArchiveExtractor : IArchiveExtractor + { + public void ExtractToDirectory( + string archiveFilePath, + string destinationDirectory, + ArchiveExtractionOptions? options = null, + CancellationToken cancellationToken = default) + { + throw new InvalidOperationException("Extraction should not be used for non-archive files."); + } + } + + private sealed class RecordingArchiveExtractor : IArchiveExtractor + { + private readonly string _extractedFileName; + private readonly string _extractedContents; + + public RecordingArchiveExtractor(string extractedFileName, string extractedContents) + { + _extractedFileName = extractedFileName; + _extractedContents = extractedContents; + } + + public string? ArchiveFileName { get; private set; } + + public bool? ConvertBigFilesToGib { get; private set; } + + public void ExtractToDirectory( + string archiveFilePath, + string destinationDirectory, + ArchiveExtractionOptions? options = null, + CancellationToken cancellationToken = default) + { + ArchiveFileName = Path.GetFileName(archiveFilePath); + ConvertBigFilesToGib = options?.ConvertBigFilesToGib; + File.WriteAllText( + Path.Combine(destinationDirectory, _extractedFileName), + _extractedContents); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Services/WindowsSystemClockServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Services/WindowsSystemClockServiceTests.cs new file mode 100644 index 00000000..a7c9169a --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Services/WindowsSystemClockServiceTests.cs @@ -0,0 +1,308 @@ +using System; +using System.ComponentModel; +using System.IO; +using System.Net.Sockets; +using GenLauncherGO.Infrastructure.Updating.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Services; + +public sealed class WindowsSystemClockServiceTests +{ + [Fact] + public void ConvertNtpResponseToLocalTimeThrowsWhenDataIsNull() + { + // Act + Action act = () => WindowsSystemClockService.ConvertNtpResponseToLocalTime(null!); + + // Assert + act.Should().Throw().WithParameterName("ntpData"); + } + + [Fact] + public void ConvertNtpResponseToLocalTimeReturnsDefaultWhenTimestampIsEmpty() + { + // Act + DateTime result = WindowsSystemClockService.ConvertNtpResponseToLocalTime(new byte[48]); + + // Assert + result.Should().Be(default); + } + + [Fact] + public void ConvertNtpResponseToLocalTimeReturnsLocalTimeFromServerReplyTimestamp() + { + // Arrange + DateTime utcTime = new(2026, 6, 21, 19, 30, 15, DateTimeKind.Utc); + byte[] ntpData = CreateNtpResponse(utcTime); + + // Act + DateTime result = WindowsSystemClockService.ConvertNtpResponseToLocalTime(ntpData); + + // Assert + result.Should().Be(utcTime.ToLocalTime()); + } + + [Fact] + public void ConstructorThrowsWhenDependenciesAreNull() + { + // Arrange + Func getNetworkTime = () => DateTime.UtcNow; + Func getLocalTime = () => DateTimeOffset.Now; + Func setSystemTimeUtc = _ => true; + Func getLastWin32Error = () => 0; + + // Act + Action nullPublicLogger = () => new WindowsSystemClockService(null!); + Action nullInternalLogger = () => new WindowsSystemClockService( + null!, + getNetworkTime, + getLocalTime, + setSystemTimeUtc, + getLastWin32Error); + Action nullNetworkTime = () => new WindowsSystemClockService( + NullLogger.Instance, + null!, + getLocalTime, + setSystemTimeUtc, + getLastWin32Error); + Action nullLocalTime = () => new WindowsSystemClockService( + NullLogger.Instance, + getNetworkTime, + null!, + setSystemTimeUtc, + getLastWin32Error); + Action nullSetSystemTime = () => new WindowsSystemClockService( + NullLogger.Instance, + getNetworkTime, + getLocalTime, + null!, + getLastWin32Error); + Action nullLastError = () => new WindowsSystemClockService( + NullLogger.Instance, + getNetworkTime, + getLocalTime, + setSystemTimeUtc, + null!); + + // Assert + nullPublicLogger.Should().Throw().WithParameterName("logger"); + nullInternalLogger.Should().Throw().WithParameterName("logger"); + nullNetworkTime.Should().Throw().WithParameterName("getNetworkTime"); + nullLocalTime.Should().Throw().WithParameterName("getLocalTime"); + nullSetSystemTime.Should().Throw().WithParameterName("setSystemTimeUtc"); + nullLastError.Should().Throw().WithParameterName("getLastWin32Error"); + } + + [Fact] + public void IsSystemTimeOutOfSyncReturnsTrueWhenClockDriftReachesLimit() + { + // Arrange + DateTimeOffset localTime = new(2026, 6, 21, 12, 0, 0, TimeSpan.FromHours(-7)); + WindowsSystemClockService service = CreateService( + () => localTime.DateTime.AddMinutes(15), + () => localTime); + + // Act + bool result = service.IsSystemTimeOutOfSync(); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void IsSystemTimeOutOfSyncReturnsTrueWhenClockDriftReachesNegativeLimit() + { + // Arrange + DateTimeOffset localTime = new(2026, 6, 21, 12, 0, 0, TimeSpan.FromHours(-7)); + WindowsSystemClockService service = CreateService( + () => localTime.DateTime.AddMinutes(-15), + () => localTime); + + // Act + bool result = service.IsSystemTimeOutOfSync(); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void IsSystemTimeOutOfSyncReturnsFalseWhenClockDriftIsBelowLimit() + { + // Arrange + DateTimeOffset localTime = new(2026, 6, 21, 12, 0, 0, TimeSpan.FromHours(-7)); + WindowsSystemClockService service = CreateService( + () => localTime.DateTime.AddMinutes(14).AddSeconds(59), + () => localTime); + + // Act + bool result = service.IsSystemTimeOutOfSync(); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void IsSystemTimeOutOfSyncReturnsFalseWhenNetworkTimeIsUnavailable() + { + // Arrange + WindowsSystemClockService service = CreateService(() => default); + + // Act + bool result = service.IsSystemTimeOutOfSync(); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void IsSystemTimeOutOfSyncReturnsFalseWhenNetworkReadFails() + { + // Arrange + WindowsSystemClockService service = CreateService(() => throw new IOException("NTP unavailable.")); + + // Act + bool result = service.IsSystemTimeOutOfSync(); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void IsSystemTimeOutOfSyncReturnsFalseWhenSocketReadFails() + { + // Arrange + WindowsSystemClockService service = CreateService(() => throw new SocketException()); + + // Act + bool result = service.IsSystemTimeOutOfSync(); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void TrySynchronizeSystemTimeWithNetworkTimeAppliesUtcNetworkTime() + { + // Arrange + DateTime networkTime = new(2026, 6, 21, 12, 30, 15); + DateTimeOffset localTime = new(2026, 6, 21, 12, 0, 0, TimeSpan.FromHours(-7)); + DateTime? updatedTime = null; + WindowsSystemClockService service = CreateService( + () => networkTime, + () => localTime, + utcTime => + { + updatedTime = utcTime; + return true; + }); + + // Act + bool result = service.TrySynchronizeSystemTimeWithNetworkTime(); + + // Assert + result.Should().BeTrue(); + updatedTime.Should().Be(networkTime.Subtract(localTime.Offset)); + } + + [Fact] + public void TrySynchronizeSystemTimeWithNetworkTimeReturnsFalseWhenNetworkTimeIsUnavailable() + { + // Arrange + bool setSystemTimeCalled = false; + WindowsSystemClockService service = CreateService( + () => default, + setSystemTimeUtc: _ => + { + setSystemTimeCalled = true; + return true; + }); + + // Act + bool result = service.TrySynchronizeSystemTimeWithNetworkTime(); + + // Assert + result.Should().BeFalse(); + setSystemTimeCalled.Should().BeFalse(); + } + + [Fact] + public void TrySynchronizeSystemTimeWithNetworkTimeReturnsFalseWhenNativeUpdateFails() + { + // Arrange + WindowsSystemClockService service = CreateService( + () => new DateTime(2026, 6, 21, 12, 30, 15), + setSystemTimeUtc: _ => false, + getLastWin32Error: () => 1314); + + // Act + bool result = service.TrySynchronizeSystemTimeWithNetworkTime(); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void TrySynchronizeSystemTimeWithNetworkTimeReturnsFalseWhenNativeUpdateThrows() + { + // Arrange + WindowsSystemClockService service = CreateService( + () => new DateTime(2026, 6, 21, 12, 30, 15), + setSystemTimeUtc: _ => throw new Win32Exception(1314)); + + // Act + bool result = service.TrySynchronizeSystemTimeWithNetworkTime(); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void TrySynchronizeSystemTimeWithNetworkTimeReturnsFalseWhenNetworkReadFails() + { + // Arrange + WindowsSystemClockService service = CreateService(() => throw new IOException("NTP unavailable.")); + + // Act + bool result = service.TrySynchronizeSystemTimeWithNetworkTime(); + + // Assert + result.Should().BeFalse(); + } + + private static WindowsSystemClockService CreateService( + Func getNetworkTime, + Func? getLocalTime = null, + Func? setSystemTimeUtc = null, + Func? getLastWin32Error = null) + { + return new WindowsSystemClockService( + NullLogger.Instance, + getNetworkTime, + getLocalTime ?? (() => new DateTimeOffset(2026, 6, 21, 12, 0, 0, TimeSpan.Zero)), + setSystemTimeUtc ?? (_ => true), + getLastWin32Error ?? (() => 0)); + } + + private static byte[] CreateNtpResponse(DateTime utcTime) + { + DateTime ntpEpoch = new(1900, 1, 1, 0, 0, 0, DateTimeKind.Utc); + TimeSpan elapsed = utcTime - ntpEpoch; + ulong seconds = (ulong)(elapsed.Ticks / TimeSpan.TicksPerSecond); + ulong remainingTicks = (ulong)(elapsed.Ticks % TimeSpan.TicksPerSecond); + ulong fraction = remainingTicks * 0x100000000UL / (ulong)TimeSpan.TicksPerSecond; + + byte[] ntpData = new byte[48]; + WriteBigEndianUInt32(ntpData, 40, (uint)seconds); + WriteBigEndianUInt32(ntpData, 44, (uint)fraction); + return ntpData; + } + + private static void WriteBigEndianUInt32(byte[] data, int offset, uint value) + { + data[offset] = (byte)(value >> 24); + data[offset + 1] = (byte)(value >> 16); + data[offset + 2] = (byte)(value >> 8); + data[offset + 3] = (byte)value; + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Support/BigFileVariantPathTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Support/BigFileVariantPathTests.cs new file mode 100644 index 00000000..3c72d8bc --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/BigFileVariantPathTests.cs @@ -0,0 +1,93 @@ +using System.IO; +using GenLauncherGO.Infrastructure.Updating.Support; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Support; + +public sealed class BigFileVariantPathTests +{ + [Fact] + public void GetExistingDownloadedPathPrefersRequestedBigPath() + { + // Arrange + using TestDirectory testDirectory = new(); + string bigPath = Path.Combine(testDirectory.Path, "asset.big"); + string gibPath = Path.Combine(testDirectory.Path, "asset.gib"); + File.WriteAllText(bigPath, "big"); + File.WriteAllText(gibPath, "gib"); + + // Act + string existingPath = BigFileVariantPath.GetExistingDownloadedPath(bigPath); + + // Assert + existingPath.Should().Be(bigPath); + } + + [Fact] + public void GetExistingDownloadedPathFallsBackToConvertedGibPath() + { + // Arrange + using TestDirectory testDirectory = new(); + string bigPath = Path.Combine(testDirectory.Path, "asset.big"); + string gibPath = Path.Combine(testDirectory.Path, "asset.gib"); + File.WriteAllText(gibPath, "gib"); + + // Act + string existingPath = BigFileVariantPath.GetExistingDownloadedPath(bigPath); + + // Assert + existingPath.Should().Be(gibPath); + } + + [Fact] + public void ConvertBigFileToGibMovesBigFileAndReplacesExistingGib() + { + // Arrange + using TestDirectory testDirectory = new(); + string bigPath = Path.Combine(testDirectory.Path, "asset.big"); + string gibPath = Path.Combine(testDirectory.Path, "asset.gib"); + File.WriteAllText(bigPath, "new"); + File.WriteAllText(gibPath, "old"); + + // Act + BigFileVariantPath.ConvertBigFileToGib(bigPath); + + // Assert + File.Exists(bigPath).Should().BeFalse(); + File.ReadAllText(gibPath).Should().Be("new"); + } + + [Fact] + public void PrepareBigFileResumePathMovesConvertedGibBackToBigPath() + { + // Arrange + using TestDirectory testDirectory = new(); + string bigPath = Path.Combine(testDirectory.Path, "asset.big"); + string gibPath = Path.Combine(testDirectory.Path, "asset.gib"); + File.WriteAllText(gibPath, "partial"); + + // Act + BigFileVariantPath.PrepareBigFileResumePath(bigPath); + + // Assert + File.ReadAllText(bigPath).Should().Be("partial"); + File.Exists(gibPath).Should().BeFalse(); + } + + [Fact] + public void PrepareBigFileResumePathReturnsWhenResumeMoveIsNotNeeded() + { + // Arrange + using TestDirectory testDirectory = new(); + string bigPath = Path.Combine(testDirectory.Path, "asset.big"); + string gibPath = Path.Combine(testDirectory.Path, "asset.gib"); + File.WriteAllText(bigPath, "existing"); + + // Act + BigFileVariantPath.PrepareBigFileResumePath(bigPath); + + // Assert + File.ReadAllText(bigPath).Should().Be("existing"); + File.Exists(gibPath).Should().BeFalse(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Support/DownloadLinkResolverTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Support/DownloadLinkResolverTests.cs new file mode 100644 index 00000000..f07686a5 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/DownloadLinkResolverTests.cs @@ -0,0 +1,58 @@ +using System; +using GenLauncherGO.Infrastructure.Updating.Support; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Support; + +public sealed class DownloadLinkResolverTests +{ + [Fact] + public void ResolveDirectDownloadLink_ConvertsDropboxPreviewLinkToDownloadLink() + { + // Arrange + const string link = "https://www.dropbox.com/s/example/Package.7z?dl=0"; + + // Act + string resolved = DownloadLinkResolver.ResolveDirectDownloadLink(link); + + // Assert + resolved.Should().Be("https://www.dropbox.com/s/example/Package.7z?dl=1"); + } + + [Fact] + public void ResolveDirectDownloadLink_ConvertsOneDriveEmbedLinkToDownloadLink() + { + // Arrange + const string link = "https://onedrive.live.com/embed?cid=abc&resid=abc%211"; + + // Act + string resolved = DownloadLinkResolver.ResolveDirectDownloadLink(link); + + // Assert + resolved.Should().Be("https://onedrive.live.com/download?cid=abc&resid=abc%211"); + } + + [Fact] + public void ResolveDirectDownloadLink_ConvertsOneDriveShareLinkToDownloadLink() + { + // Arrange + const string link = + "https://onedrive.live.com/?authkey=%21key&cid=896C9369E9176506&id=896C9369E9176506%21464&parId=896C9369E9176506%21463&o=OneUp"; + + // Act + string resolved = DownloadLinkResolver.ResolveDirectDownloadLink(link); + + // Assert + resolved.Should() + .Be("https://onedrive.live.com/download?cid=896C9369E9176506&resid=896C9369E9176506%21464&authkey=%21key"); + } + + [Fact] + public void ResolveDownloadUri_ThrowsForMissingLink() + { + // Arrange / Act + Action act = () => DownloadLinkResolver.ResolveDownloadUri(" "); + + // Assert + act.Should().Throw(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Support/ManifestPathResolverTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Support/ManifestPathResolverTests.cs new file mode 100644 index 00000000..f59ad752 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/ManifestPathResolverTests.cs @@ -0,0 +1,63 @@ +using System; +using System.IO; +using GenLauncherGO.Infrastructure.Updating.Support; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Support; + +public sealed class ManifestPathResolverTests +{ + [Theory] + [InlineData("Data/INI/GameData.ini")] + [InlineData(@"Data\INI\GameData.ini")] + [InlineData(" Data/INI/GameData.ini ")] + public void ResolvePathReturnsFullPathUnderRoot(string manifestFileName) + { + // Arrange + using TestDirectory directory = new(); + + // Act + string result = ManifestPathResolver.ResolvePath(directory.Path, manifestFileName); + + // Assert + result.Should().Be(Path.GetFullPath(Path.Combine(directory.Path, "Data", "INI", "GameData.ini"))); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ResolvePathThrowsForMissingRequiredValues(string missingValue) + { + // Act + Action missingRoot = () => ManifestPathResolver.ResolvePath(missingValue, "Data/file.txt"); + Action missingFile = () => ManifestPathResolver.ResolvePath(@"C:\Package", missingValue); + + // Assert + missingRoot.Should().Throw(); + missingFile.Should().Throw(); + } + + [Theory] + [InlineData(@"C:\Package\Data.big")] + [InlineData("C:Package/Data.big")] + [InlineData("../Data.big")] + [InlineData("./Data.big")] + public void NormalizeRelativePathRejectsUnsafePaths(string manifestFileName) + { + // Act + Action act = () => ManifestPathResolver.NormalizeRelativePath(manifestFileName); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void NormalizeForManifestIndexUsesSlashSeparators() + { + // Act + string result = ManifestPathResolver.NormalizeForManifestIndex(@"Data\INI\GameData.ini"); + + // Assert + result.Should().Be("Data/INI/GameData.ini"); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageInstallFolderReplacerTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageInstallFolderReplacerTests.cs new file mode 100644 index 00000000..e5b63b45 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageInstallFolderReplacerTests.cs @@ -0,0 +1,208 @@ +using System; +using System.IO; +using GenLauncherGO.Infrastructure.Updating.Support; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Support; + +public sealed class PackageInstallFolderReplacerTests +{ + [Fact] + public void ReplaceMovesTemporaryFolderIntoNewInstalledLocation() + { + // Arrange + using TestDirectory testDirectory = new(); + string temporaryFolder = Path.Combine(testDirectory.Path, "staging", "Mod", "1.0"); + string installedFolder = Path.Combine(testDirectory.Path, "installed", "Mod", "1.0"); + Directory.CreateDirectory(temporaryFolder); + File.WriteAllText(Path.Combine(temporaryFolder, "asset.txt"), "new"); + + // Act + PackageInstallFolderReplacer.Replace( + temporaryFolder, + installedFolder, + NullLogger.Instance); + + // Assert + Directory.Exists(temporaryFolder).Should().BeFalse(); + File.ReadAllText(Path.Combine(installedFolder, "asset.txt")).Should().Be("new"); + } + + [Fact] + public void ReplaceMovesExistingInstallToBackupBeforeReplacingIt() + { + // Arrange + using TestDirectory testDirectory = new(); + string temporaryFolder = Path.Combine(testDirectory.Path, "staging", "Mod", "1.0"); + string installedFolder = Path.Combine(testDirectory.Path, "installed", "Mod", "1.0"); + Directory.CreateDirectory(temporaryFolder); + Directory.CreateDirectory(installedFolder); + File.WriteAllText(Path.Combine(temporaryFolder, "asset.txt"), "new"); + File.WriteAllText(Path.Combine(installedFolder, "asset.txt"), "old"); + + // Act + PackageInstallFolderReplacer.Replace( + temporaryFolder, + installedFolder, + NullLogger.Instance); + + // Assert + File.ReadAllText(Path.Combine(installedFolder, "asset.txt")).Should().Be("new"); + Directory + .EnumerateDirectories(Path.GetDirectoryName(installedFolder)!, "1.0.backup-*") + .Should() + .BeEmpty(); + } + + [Fact] + public void ReplaceThrowsWhenTemporaryFolderDoesNotExist() + { + // Arrange + using TestDirectory testDirectory = new(); + string temporaryFolder = Path.Combine(testDirectory.Path, "missing"); + string installedFolder = Path.Combine(testDirectory.Path, "installed"); + + // Act + Action act = () => PackageInstallFolderReplacer.Replace( + temporaryFolder, + installedFolder, + NullLogger.Instance); + + // Assert + act.Should().Throw() + .WithMessage("*Temporary package folder*"); + } + + [Fact] + public void ReplaceThrowsWhenTemporaryTreeContainsReparsePoint() + { + // Arrange + using TestDirectory testDirectory = new(); + string temporaryFolder = Path.Combine(testDirectory.Path, "staging", "Mod", "1.0"); + string installedFolder = Path.Combine(testDirectory.Path, "installed", "Mod", "1.0"); + string linkTarget = Path.Combine(testDirectory.Path, "linked-target"); + string linkPath = Path.Combine(temporaryFolder, "Linked"); + Directory.CreateDirectory(temporaryFolder); + Directory.CreateDirectory(linkTarget); + File.WriteAllText(Path.Combine(temporaryFolder, "asset.txt"), "new"); + if (!TryCreateDirectoryLink(linkPath, linkTarget)) + { + return; + } + + // Act + Action act = () => PackageInstallFolderReplacer.Replace( + temporaryFolder, + installedFolder, + NullLogger.Instance); + + // Assert + act.Should().Throw() + .WithMessage("*reparse point*"); + Directory.Exists(temporaryFolder).Should().BeTrue(); + Directory.Exists(installedFolder).Should().BeFalse(); + } + + [Fact] + public void ReplaceThrowsWhenInstalledPathChainContainsReparsePoint() + { + // Arrange + using TestDirectory testDirectory = new(); + string temporaryFolder = Path.Combine(testDirectory.Path, "staging", "Mod", "1.0"); + string realInstalledRoot = Path.Combine(testDirectory.Path, "real-installed"); + string linkedInstalledRoot = Path.Combine(testDirectory.Path, "installed-link"); + string installedFolder = Path.Combine(linkedInstalledRoot, "Mod", "1.0"); + Directory.CreateDirectory(temporaryFolder); + Directory.CreateDirectory(realInstalledRoot); + File.WriteAllText(Path.Combine(temporaryFolder, "asset.txt"), "new"); + if (!TryCreateDirectoryLink(linkedInstalledRoot, realInstalledRoot)) + { + return; + } + + // Act + Action act = () => PackageInstallFolderReplacer.Replace( + temporaryFolder, + installedFolder, + NullLogger.Instance); + + // Assert + act.Should().Throw() + .WithMessage("*reparse point*"); + Directory.Exists(temporaryFolder).Should().BeTrue(); + File.Exists(Path.Combine(realInstalledRoot, "Mod", "1.0", "asset.txt")).Should().BeFalse(); + } + + [Theory] + [InlineData("", "installed")] + [InlineData(" ", "installed")] + [InlineData("temporary", "")] + [InlineData("temporary", " ")] + public void ReplaceThrowsWhenRequiredPathIsMissing(string temporaryFolder, string installedFolder) + { + // Act + Action act = () => PackageInstallFolderReplacer.Replace( + temporaryFolder, + installedFolder, + NullLogger.Instance); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void ReplaceThrowsWhenLoggerIsNull() + { + // Arrange + using TestDirectory testDirectory = new(); + string temporaryFolder = Path.Combine(testDirectory.Path, "temporary"); + string installedFolder = Path.Combine(testDirectory.Path, "installed"); + + // Act + Action act = () => PackageInstallFolderReplacer.Replace( + temporaryFolder, + installedFolder, + null!); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void ReplaceRestoresExistingInstallWhenReplacementMoveFails() + { + // Arrange + using TestDirectory testDirectory = new(); + string installedFolder = Path.Combine(testDirectory.Path, "installed", "Mod", "1.0"); + Directory.CreateDirectory(installedFolder); + File.WriteAllText(Path.Combine(installedFolder, "asset.txt"), "old"); + + // Act + Action act = () => PackageInstallFolderReplacer.Replace( + installedFolder, + installedFolder, + NullLogger.Instance); + + // Assert + act.Should().Throw(); + File.ReadAllText(Path.Combine(installedFolder, "asset.txt")).Should().Be("old"); + Directory + .EnumerateDirectories(Path.GetDirectoryName(installedFolder)!, "1.0.backup-*") + .Should() + .BeEmpty(); + } + + private static bool TryCreateDirectoryLink(string linkPath, string targetPath) + { + try + { + Directory.CreateSymbolicLink(linkPath, targetPath); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return false; + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageProgressTrackerTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageProgressTrackerTests.cs new file mode 100644 index 00000000..bde5ce5e --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageProgressTrackerTests.cs @@ -0,0 +1,105 @@ +using System; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Support; + +public sealed class PackageProgressTrackerTests +{ + [Fact] + public void Update_ReturnsAggregateProgressForKnownTotal() + { + // Arrange + PackageProgressTracker tracker = new(200); + + // Act + PackageUpdateProgress? progress = tracker.Update("file-a", 50); + + // Assert + progress.Should().NotBeNull(); + progress!.TotalBytes.Should().Be(200); + progress.BytesRead.Should().Be(50); + progress.ProgressPercentage.Should().Be(25); + } + + [Fact] + public void Update_TracksItemDeltasAndClampsNegativeValues() + { + // Arrange + PackageProgressTracker tracker = new(100); + + // Act + tracker.Update("file-a", 80); + PackageUpdateProgress? progress = tracker.Update("file-a", -5, true); + + // Assert + progress.Should().NotBeNull(); + progress!.BytesRead.Should().Be(0); + progress.ProgressPercentage.Should().Be(0); + } + + [Fact] + public void Update_ThrottlesRepeatedReportsUntilForced() + { + // Arrange + PackageProgressTracker tracker = new(100); + + // Act + tracker.Update("file-a", 10); + PackageUpdateProgress? throttledProgress = tracker.Update("file-a", 20); + PackageUpdateProgress? forcedProgress = tracker.Update("file-a", 20, true); + + // Assert + throttledProgress.Should().BeNull(); + forcedProgress.Should().NotBeNull(); + } + + [Fact] + public void AddExpectedBytes_IncreasesKnownTotal() + { + // Arrange + PackageProgressTracker tracker = new(100); + + // Act + tracker.AddExpectedBytes(50); + PackageUpdateProgress? progress = tracker.Update("file-a", 75); + + // Assert + progress.Should().NotBeNull(); + progress!.TotalBytes.Should().Be(150); + progress.ProgressPercentage.Should().Be(50); + } + + [Fact] + public void AddExpectedBytes_IgnoresNonPositiveValues() + { + // Arrange + PackageProgressTracker tracker = new(100); + + // Act + tracker.AddExpectedBytes(0); + tracker.AddExpectedBytes(-1); + PackageUpdateProgress? progress = tracker.Update("file-a", 50); + + // Assert + progress.Should().NotBeNull(); + progress!.TotalBytes.Should().Be(100); + } + + [Fact] + public async Task Update_ReportsSpeedAndEtaAfterEnoughElapsedTimeAsync() + { + // Arrange + PackageProgressTracker tracker = new(200); + + // Act + await Task.Delay(TimeSpan.FromMilliseconds(300)); + PackageUpdateProgress? progress = tracker.Update("file-a", 50, true); + + // Assert + progress.Should().NotBeNull(); + progress!.DownloadSpeedBytesPerSecond.Should().BeGreaterThan(0); + progress.EstimatedTimeRemaining.Should().NotBeNull(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageStagingFolderCleanerTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageStagingFolderCleanerTests.cs new file mode 100644 index 00000000..97fa25db --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageStagingFolderCleanerTests.cs @@ -0,0 +1,203 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; +using GenLauncherGO.Tests.Testing; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Support; + +public sealed class PackageStagingFolderCleanerTests +{ + [Fact] + public void ClearDirectoryCreatesStagingFolderAndDeletesExistingChildren() + { + // Arrange + using TestDirectory testDirectory = new(); + string stagingFolder = Path.Combine(testDirectory.Path, "Packages", "Mod", "1.0"); + string childFolder = Path.Combine(stagingFolder, "Data"); + Directory.CreateDirectory(childFolder); + File.WriteAllText(Path.Combine(stagingFolder, "stale.txt"), "stale"); + File.WriteAllText(Path.Combine(childFolder, "nested.txt"), "nested"); + + // Act + PackageStagingFolderCleaner.ClearDirectory(stagingFolder, NullLogger.Instance); + + // Assert + Directory.Exists(stagingFolder).Should().BeTrue(); + Directory.EnumerateFileSystemEntries(stagingFolder).Should().BeEmpty(); + } + + [Fact] + public void DeleteEmptyPackageParentsRemovesEmptyChainThroughPackagesFolder() + { + // Arrange + using TestDirectory testDirectory = new(); + string stagingFolder = Path.Combine(testDirectory.Path, "Packages", "Mod", "1.0"); + string packageFolder = Path.GetDirectoryName(stagingFolder)!; + string packagesFolder = Path.GetDirectoryName(packageFolder)!; + Directory.CreateDirectory(packageFolder); + + // Act + PackageStagingFolderCleaner.DeleteEmptyPackageParents(stagingFolder, NullLogger.Instance); + + // Assert + Directory.Exists(packageFolder).Should().BeFalse(); + Directory.Exists(packagesFolder).Should().BeFalse(); + Directory.Exists(testDirectory.Path).Should().BeTrue(); + } + + [Fact] + public void DeleteEmptyPackageParentsStopsWhenParentContainsOtherEntries() + { + // Arrange + using TestDirectory testDirectory = new(); + string stagingFolder = Path.Combine(testDirectory.Path, "Packages", "Mod", "1.0"); + string packageFolder = Path.GetDirectoryName(stagingFolder)!; + string packagesFolder = Path.GetDirectoryName(packageFolder)!; + Directory.CreateDirectory(packageFolder); + File.WriteAllText(Path.Combine(packagesFolder, "keep.txt"), "keep"); + + // Act + PackageStagingFolderCleaner.DeleteEmptyPackageParents(stagingFolder, NullLogger.Instance); + + // Assert + Directory.Exists(packageFolder).Should().BeFalse(); + Directory.Exists(packagesFolder).Should().BeTrue(); + File.Exists(Path.Combine(packagesFolder, "keep.txt")).Should().BeTrue(); + } + + [Fact] + public void DeleteEmptyPackageParentsReturnsWhenPathIsNotUnderPackagesFolder() + { + // Arrange + using TestDirectory testDirectory = new(); + string stagingFolder = Path.Combine(testDirectory.Path, "Mod", "1.0"); + string packageFolder = Path.GetDirectoryName(stagingFolder)!; + Directory.CreateDirectory(packageFolder); + + // Act + PackageStagingFolderCleaner.DeleteEmptyPackageParents(stagingFolder, NullLogger.Instance); + + // Assert + Directory.Exists(packageFolder).Should().BeTrue(); + } + + [Fact] + public void DeleteEmptyPackageParentsReturnsWhenPackagesAncestorDoesNotExist() + { + // Arrange + using TestDirectory testDirectory = new(); + string stagingFolder = Path.Combine(testDirectory.Path, "Packages", "Mod", "1.0"); + + // Act + Action act = () => PackageStagingFolderCleaner.DeleteEmptyPackageParents( + stagingFolder, + NullLogger.Instance); + + // Assert + act.Should().NotThrow(); + Directory.Exists(Path.Combine(testDirectory.Path, "Packages")).Should().BeFalse(); + } + + [Fact] + public void RemoveUnsafeLinksHonorsPreCanceledToken() + { + // Arrange + using TestDirectory testDirectory = new(); + string stagingFolder = Path.Combine(testDirectory.Path, "Packages", "Mod", "1.0"); + Directory.CreateDirectory(stagingFolder); + File.WriteAllText(Path.Combine(stagingFolder, "file.txt"), "file"); + using CancellationTokenSource cancellationTokenSource = new(); + cancellationTokenSource.Cancel(); + + // Act + Action act = () => PackageStagingFolderCleaner.RemoveUnsafeLinks( + stagingFolder, + NullLogger.Instance, + cancellationTokenSource.Token); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void RemoveUnsafeLinksRecursesThroughOrdinaryDirectories() + { + // Arrange + using TestDirectory testDirectory = new(); + string stagingFolder = Path.Combine(testDirectory.Path, "Packages", "Mod", "1.0"); + string nestedFolder = Path.Combine(stagingFolder, "Data"); + Directory.CreateDirectory(nestedFolder); + string filePath = Path.Combine(nestedFolder, "file.txt"); + File.WriteAllText(filePath, "file"); + + // Act + PackageStagingFolderCleaner.RemoveUnsafeLinks( + stagingFolder, + NullLogger.Instance, + CancellationToken.None); + + // Assert + File.Exists(filePath).Should().BeTrue(); + } + + [Fact] + public void PruneToManifestThrowsForMissingFiles() + { + // Arrange + using TestDirectory testDirectory = new(); + string stagingFolder = Path.Combine(testDirectory.Path, "Packages", "Mod", "1.0"); + + // Act + Action act = () => PackageStagingFolderCleaner.PruneToManifest( + stagingFolder, + null!, + NullLogger.Instance, + CancellationToken.None); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void PruneToManifestDeletesStaleFilesAndKeepsConvertedBigFiles() + { + // Arrange + using TestDirectory testDirectory = new(); + string stagingFolder = Path.Combine(testDirectory.Path, "Packages", "Mod", "1.0"); + string nestedFolder = Path.Combine(stagingFolder, "Data"); + string emptyFolder = Path.Combine(stagingFolder, "Empty"); + Directory.CreateDirectory(nestedFolder); + Directory.CreateDirectory(emptyFolder); + File.WriteAllText(Path.Combine(stagingFolder, "keep.txt"), "keep"); + File.WriteAllText(Path.Combine(stagingFolder, "stale.txt"), "stale"); + File.WriteAllText(Path.Combine(nestedFolder, "asset.gib"), "asset"); + File.WriteAllText(Path.Combine(nestedFolder, "old.txt"), "old"); + RemoteFileManifestEntry[] files = + { + new("keep.txt", "hash", 4), + new("Data/asset.big", "hash", 5), + }; + + // Act + PackageStagingFolderCleaner.PruneToManifest( + stagingFolder, + files, + NullLogger.Instance, + CancellationToken.None); + + // Assert + File.Exists(Path.Combine(stagingFolder, "keep.txt")).Should().BeTrue(); + File.Exists(Path.Combine(nestedFolder, "asset.gib")).Should().BeTrue(); + File.Exists(Path.Combine(stagingFolder, "stale.txt")).Should().BeFalse(); + File.Exists(Path.Combine(nestedFolder, "old.txt")).Should().BeFalse(); + Directory.Exists(emptyFolder).Should().BeFalse(); + Directory.EnumerateFiles(stagingFolder, "*", SearchOption.AllDirectories) + .Select(Path.GetFileName) + .Should() + .BeEquivalentTo("keep.txt", "asset.gib"); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Support/S3HashValidationPolicyTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Support/S3HashValidationPolicyTests.cs new file mode 100644 index 00000000..bd11562a --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/S3HashValidationPolicyTests.cs @@ -0,0 +1,92 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Support; + +public sealed class S3HashValidationPolicyTests +{ + [Theory] + [InlineData("0123456789abcdef0123456789abcdef")] + [InlineData("0123456789ABCDEF0123456789ABCDEF")] + [InlineData("0123456789abcdef0123456789ABCDEF")] + public void IsReliableMd5HashReturnsTrueForPlainHexMd5(string hash) + { + // Act + bool result = S3HashValidationPolicy.IsReliableMd5Hash(hash); + + // Assert + result.Should().BeTrue(); + } + + [Theory] + [InlineData("")] + [InlineData("0123456789abcdef0123456789abcde")] + [InlineData("0123456789abcdef0123456789abcdef-2")] + [InlineData("0123456789abcdef0123456789abcdeg")] + public void IsReliableMd5HashReturnsFalseForMultipartOrMalformedHashes(string hash) + { + // Act + bool result = S3HashValidationPolicy.IsReliableMd5Hash(hash); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void ShouldCheckHashReturnsTrueWhenExtensionRequiresReliableMd5Validation() + { + // Arrange + RemoteFileManifestEntry file = new( + "Data/asset.big", + "0123456789abcdef0123456789abcdef", + 10); + HashSet hashCheckedExtensions = new( + new[] { ".big" }, + System.StringComparer.OrdinalIgnoreCase); + + // Act + bool result = S3HashValidationPolicy.ShouldCheckHash(file, hashCheckedExtensions); + + // Assert + result.Should().BeTrue(); + } + + [Fact] + public void ShouldCheckHashReturnsFalseForUncheckedExtension() + { + // Arrange + RemoteFileManifestEntry file = new( + "Data/readme.txt", + "0123456789abcdef0123456789abcdef", + 10); + HashSet hashCheckedExtensions = new( + new[] { ".big" }, + System.StringComparer.OrdinalIgnoreCase); + + // Act + bool result = S3HashValidationPolicy.ShouldCheckHash(file, hashCheckedExtensions); + + // Assert + result.Should().BeFalse(); + } + + [Fact] + public void ShouldCheckHashReturnsFalseForUnreliableHash() + { + // Arrange + RemoteFileManifestEntry file = new( + "Data/asset.big", + "0123456789abcdef0123456789abcdef-2", + 10); + HashSet hashCheckedExtensions = new( + new[] { ".big" }, + System.StringComparer.OrdinalIgnoreCase); + + // Act + bool result = S3HashValidationPolicy.ShouldCheckHash(file, hashCheckedExtensions); + + // Assert + result.Should().BeFalse(); + } +} diff --git a/GenLauncherGO.Tests/Testing/.gitkeep b/GenLauncherGO.Tests/Testing/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/.gitkeep @@ -0,0 +1 @@ + diff --git a/GenLauncherGO.Tests/Testing/StaTestRunner.cs b/GenLauncherGO.Tests/Testing/StaTestRunner.cs new file mode 100644 index 00000000..d1c21dd2 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/StaTestRunner.cs @@ -0,0 +1,78 @@ +using System; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Runs WPF-dependent test code on an STA thread. +/// +internal static class StaTestRunner +{ + /// + /// Runs the supplied action on an STA thread. + /// + /// The action to run. + public static void Run(Action action) + { + ArgumentNullException.ThrowIfNull(action); + + Run(() => + { + action(); + return null; + }); + } + + /// + /// Runs the supplied asynchronous action on an STA thread. + /// + /// The asynchronous action to run. + public static void Run(Func action) + { + ArgumentNullException.ThrowIfNull(action); + + Run(() => + { + action().GetAwaiter().GetResult(); + return true; + }); + } + + /// + /// Runs the supplied function on an STA thread and returns its result. + /// + /// The result type. + /// The function to run. + /// The function result. + public static TResult Run(Func function) + { + ArgumentNullException.ThrowIfNull(function); + + Exception? exception = null; + TResult? result = default; + Thread thread = new(() => + { + try + { + result = function(); + } + catch (Exception caughtException) + { + exception = caughtException; + } + }); + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + + if (exception != null) + { + ExceptionDispatchInfo.Capture(exception).Throw(); + } + + return result!; + } +} diff --git a/GenLauncherGO.Tests/Testing/TestDirectory.cs b/GenLauncherGO.Tests/Testing/TestDirectory.cs new file mode 100644 index 00000000..774e0f9c --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestDirectory.cs @@ -0,0 +1,35 @@ +using System; +using System.IO; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Owns a temporary directory for a test and deletes it during disposal. +/// +internal sealed class TestDirectory : IDisposable +{ + /// + /// Initializes a new instance of the class. + /// + public TestDirectory() + { + Path = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + /// + /// Gets the temporary directory path. + /// + public string Path { get; } + + /// + /// Deletes the temporary directory and all children when it still exists. + /// + public void Dispose() + { + if (Directory.Exists(Path)) + { + Directory.Delete(Path, recursive: true); + } + } +} diff --git a/GenLauncherGO.Tests/Testing/TestLauncherModsContext.cs b/GenLauncherGO.Tests/Testing/TestLauncherModsContext.cs new file mode 100644 index 00000000..f039fed1 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestLauncherModsContext.cs @@ -0,0 +1,30 @@ +using GenLauncherGO.Core.Startup; +using GenLauncherGO.UI.Features.Mods.Contracts; +using GenLauncherGO.UI.Shared.Themes; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Provides fixed launcher mod context values for UI tests. +/// +internal sealed class TestLauncherModsContext : ILauncherModsContext +{ + /// + /// Initializes a new instance of the class. + /// + /// The game exposed to the test subject. + /// The colors exposed to the test subject. + public TestLauncherModsContext( + SupportedGame currentlyManagedGame = SupportedGame.ZeroHour, + ColorsInfo? colors = null) + { + CurrentlyManagedGame = currentlyManagedGame; + Colors = colors ?? new ColorsInfo(); + } + + /// + public SupportedGame CurrentlyManagedGame { get; } + + /// + public ColorsInfo Colors { get; } +} diff --git a/GenLauncherGO.Tests/Testing/TestStringLocalizer.cs b/GenLauncherGO.Tests/Testing/TestStringLocalizer.cs new file mode 100644 index 00000000..0f4ea99f --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestStringLocalizer.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.UI.Shared.Localization; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Resolves test localization keys from an optional in-memory dictionary. +/// +internal sealed class TestStringLocalizer : ILauncherStringLocalizer +{ + /// + /// The configured localized values. + /// + private readonly IReadOnlyDictionary _values; + + /// + /// Creates a fallback value for missing keys. + /// + private readonly Func _fallback; + + /// + /// Initializes a new instance of the class. + /// + public TestStringLocalizer() + : this(new Dictionary + { + ["LatestVersion"] = "Latest version: ", + }) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The explicit localized values. + public TestStringLocalizer(IReadOnlyDictionary values) + : this(values, key => key) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The explicit localized values. + /// The value factory used when a key is missing. + public TestStringLocalizer( + IReadOnlyDictionary values, + Func fallback) + { + _values = values ?? throw new ArgumentNullException(nameof(values)); + _fallback = fallback ?? throw new ArgumentNullException(nameof(fallback)); + } + + /// + public string this[string key] => _values.TryGetValue(key, out string? value) ? value : _fallback(key); +} diff --git a/GenLauncherGO.Tests/UI/.gitkeep b/GenLauncherGO.Tests/UI/.gitkeep new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/GenLauncherGO.Tests/UI/.gitkeep @@ -0,0 +1 @@ + diff --git a/GenLauncherGO.Tests/UI/Features/Dialogs/DialogServiceCollectionExtensionsTests.cs b/GenLauncherGO.Tests/UI/Features/Dialogs/DialogServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..c37f3b11 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Dialogs/DialogServiceCollectionExtensionsTests.cs @@ -0,0 +1,82 @@ +using GenLauncherGO.Core.Startup; +using GenLauncherGO.UI.Features.Dialogs.Composition; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Services; +using GenLauncherGO.UI.Features.Mods.Contracts; +using GenLauncherGO.UI.Shared.Localization; +using GenLauncherGO.UI.Shared.Themes; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Tests.UI.Features.Dialogs; + +public sealed class DialogServiceCollectionExtensionsTests +{ + [Fact] + public void AddGenLauncherGoDialogs_RegistersDialogWindowFactory() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoDialogs(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(ILauncherDialogWindowFactory) && + descriptor.ImplementationType == typeof(LauncherDialogWindowFactory) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoDialogs_RegistersDialogViewModelFactory() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoDialogs(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherDialogViewModelFactory) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoDialogs_RegistersLauncherDialogService() + { + // Arrange + ServiceCollection services = new(); + ILauncherModsContext launcherContext = Substitute.For(); + launcherContext.CurrentlyManagedGame.Returns(SupportedGame.ZeroHour); + launcherContext.Colors.Returns(CreateColors()); + services.AddSingleton(launcherContext); + services.AddSingleton(Substitute.For()); + + // Act + services.AddGenLauncherGoDialogs(); + using ServiceProvider provider = services.BuildServiceProvider(); + + // Assert + provider.GetRequiredService() + .Should() + .BeOfType(); + } + + private static ColorsInfo CreateColors() + { + return new ColorsInfo( + "#00e3ff", + "DarkGray", + "#7a7db0", + "#baff0c", + "#232977", + "#090502", + "#B3000000", + "White", + "White", + "#F21d2057", + "#E61d2057", + "#2534ff"); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Dialogs/Models/ManualModificationDialogRequestTests.cs b/GenLauncherGO.Tests/UI/Features/Dialogs/Models/ManualModificationDialogRequestTests.cs new file mode 100644 index 00000000..f1a99e92 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Dialogs/Models/ManualModificationDialogRequestTests.cs @@ -0,0 +1,32 @@ +using System; +using GenLauncherGO.UI.Features.Dialogs.Models; + +namespace GenLauncherGO.Tests.UI.Features.Dialogs.Models; + +public sealed class ManualModificationDialogRequestTests +{ + [Fact] + public void ConstructorCopiesSelectedFilesAndParentContentName() + { + // Arrange + string[] files = { @"C:\Packages\mod.zip" }; + + // Act + ManualModificationDialogRequest request = new(files, "ShockWave"); + files[0] = @"C:\Packages\changed.zip"; + + // Assert + request.Files.Should().Equal(@"C:\Packages\mod.zip"); + request.ParentContentName.Should().Be("ShockWave"); + } + + [Fact] + public void ConstructorThrowsForMissingFiles() + { + // Arrange + Action act = () => new ManualModificationDialogRequest(null!); + + // Act and Assert + act.Should().Throw(); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Dialogs/Services/LauncherDialogViewModelFactoryTests.cs b/GenLauncherGO.Tests/UI/Features/Dialogs/Services/LauncherDialogViewModelFactoryTests.cs new file mode 100644 index 00000000..7cb5464b --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Dialogs/Services/LauncherDialogViewModelFactoryTests.cs @@ -0,0 +1,130 @@ +using System.Collections.Generic; +using System.Windows; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Models; +using GenLauncherGO.UI.Features.Dialogs.Services; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Integrity.ViewModels; +using GenLauncherGO.UI.Features.Mods.ViewModels; + +namespace GenLauncherGO.Tests.UI.Features.Dialogs.Services; + +public sealed class LauncherDialogViewModelFactoryTests +{ + [Fact] + public void CreateAddModificationViewModelUsesAvailableModificationNames() + { + // Arrange + LauncherDialogViewModelFactory factory = CreateFactory(); + string[] names = { "Contra", "Shockwave" }; + + // Act + AddModificationViewModel viewModel = factory.CreateAddModificationViewModel(names); + + // Assert + viewModel.ModificationNames.Should().Equal(names); + } + + [Fact] + public void CreateIntegrityReviewViewModelUsesReportAndOptions() + { + // Arrange + LauncherDialogViewModelFactory factory = CreateFactory(); + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + "mod:contra", + "Contra", + ContentSourceKind.ManagedSingleFile, + IntegrityIssueKind.MissingFile, + IntegrityIssueAction.Repair, + "Data/Asset.big", + ExpectedSizeBytes: 2048), + }); + IntegrityReviewDialogOptions options = new( + "Review", + "Resolve issues", + "Repair", + "Cancel", + new Dictionary + { + [IntegrityIssueAction.Repair] = "Repair", + }, + new Dictionary + { + [IntegrityIssueKind.MissingFile] = "Missing", + }, + "{0} change", + "{0} changes"); + + // Act + IntegrityReviewViewModel viewModel = factory.CreateIntegrityReviewViewModel(report, options); + + // Assert + viewModel.Title.Should().Be("Review"); + viewModel.IssueGroups.Should().ContainSingle(group => + group.TargetDisplayName == "Contra" && + group.Summary == "1 change" && + group.Entries.Count == 1 && + group.Entries[0].ActionLabel == "Repair" && + group.Entries[0].IssueKindLabel == "Missing" && + group.Entries[0].RelativePath == "Data/Asset.big (2.0 KB)"); + } + + [Fact] + public void CreateInfoDialogViewModelUsesRequestAndKind() + { + // Arrange + LauncherDialogViewModelFactory factory = CreateFactory(); + LauncherInfoDialogRequest request = new("Careful", "This changes files", detailFontSize: 14D); + + // Act + InfoDialogViewModel viewModel = factory.CreateInfoDialogViewModel( + request, + InfoDialogKind.WarningConfirmation, + "Continue"); + + // Assert + viewModel.MainMessage.Should().Be("Careful"); + viewModel.DetailMessage.Should().Be("This changes files"); + viewModel.DetailFontSize.Should().Be(14D); + viewModel.ContinueText.Should().Be("Continue"); + viewModel.ContinueVisibility.Should().Be(Visibility.Visible); + viewModel.CancelVisibility.Should().Be(Visibility.Visible); + viewModel.OkVisibility.Should().Be(Visibility.Hidden); + } + + [Fact] + public void CreateManualModificationViewModelCarriesRequestIntoAcceptedResult() + { + // Arrange + LauncherDialogViewModelFactory factory = CreateFactory(); + ManualModificationDialogRequest request = new( + new[] { @"C:\Downloads\manual.zip" }, + "Contra"); + ILauncherDialogService dialogService = Substitute.For(); + + // Act + ManualAddModificationViewModel viewModel = factory.CreateManualModificationViewModel( + request, + dialogService); + viewModel.ModificationName = "Patch"; + viewModel.Version = "1.2"; + viewModel.AcceptCommand.Execute(null); + + // Assert + viewModel.DialogResult.Should().BeTrue(); + viewModel.ImportResult.Should().NotBeNull(); + viewModel.ImportResult!.Files.Should().Equal(request.Files); + viewModel.ImportResult.ParentContentName.Should().Be("Contra"); + viewModel.ImportResult.ModificationName.Should().Be("Patch"); + viewModel.ImportResult.Version.Should().Be("1.2"); + } + + private static LauncherDialogViewModelFactory CreateFactory() + { + return new LauncherDialogViewModelFactory(new TestStringLocalizer()); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Integrity/LaunchContentIntegrityCoordinatorTests.cs b/GenLauncherGO.Tests/UI/Features/Integrity/LaunchContentIntegrityCoordinatorTests.cs new file mode 100644 index 00000000..37593c34 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Integrity/LaunchContentIntegrityCoordinatorTests.cs @@ -0,0 +1,642 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Shared.Localization; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Integrity; + +public sealed class LaunchContentIntegrityCoordinatorTests +{ + [Fact] + public void IsManualReturnsTrueOnlyForManualVersions() + { + // Arrange + LaunchContentIntegrityCoordinator coordinator = CreateCoordinator(); + + // Act and Assert + coordinator.IsManual(CreateVersion(ContentSourceKind.Manual)).Should().BeTrue(); + coordinator.IsManual(CreateVersion(ContentSourceKind.ManagedSingleFile)).Should().BeFalse(); + coordinator.IsManual(null!).Should().BeFalse(); + } + + [Fact] + public void EnsureReadyToLaunchAsyncWhenReportHasNoIssuesReturnsTrueAndBuildsTargetRequest() + { + StaTestRunner.Run(async () => + { + // Arrange + ModificationVersion activeVersion = CreateVersion(ContentSourceKind.Manual, "Shockwave"); + ModificationVersion catalogVersion = CreateVersion(ContentSourceKind.ManagedSingleFile, "GenTool"); + LaunchContentIntegrityTargetRequest? capturedRequest = null; + ILaunchContentIntegrityResolutionService resolutionService = + Substitute.For(); + resolutionService.VerifyAsync( + Arg.Do(request => capturedRequest = request), + Arg.Any()) + .Returns(Task.FromResult(CreateVerificationResult())); + + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetAllModsVersionsList().Returns(new[] { catalogVersion }); + ILauncherDialogService dialogService = Substitute.For(); + LaunchContentIntegrityCoordinator coordinator = CreateCoordinator( + resolutionService, + catalogQueries, + dialogService: dialogService); + Window owner = new(); + + // Act + bool result = await coordinator.EnsureReadyToLaunchAsync( + new[] { activeVersion }, + Array.Empty(), + owner); + + // Assert + result.Should().BeTrue(); + capturedRequest.Should().NotBeNull(); + capturedRequest!.ActiveVersions.Should().ContainSingle().Which.Should().BeSameAs(activeVersion); + capturedRequest.AllVersions.Should().ContainSingle().Which.Should().BeSameAs(catalogVersion); + capturedRequest.CacheDisplayNameSuffix.Should().Be(" cache"); + capturedRequest.AddonsFolderName.Should().Be("Addons"); + capturedRequest.PatchesFolderName.Should().Be("Patches"); + await resolutionService.DidNotReceive().InitializeUntrackedManagedCachesAsync( + Arg.Any(), + Arg.Any()); + dialogService.DidNotReceive().ShowIntegrityReview( + Arg.Any(), + Arg.Any(), + Arg.Any()); + }); + } + + [Fact] + public void EnsureReadyToLaunchAsyncWhenRepairIsConfirmedResolvesAndReportsProgress() + { + StaTestRunner.Run(async () => + { + // Arrange + ModificationVersion version = CreateVersion(ContentSourceKind.ManagedSingleFile, "Shockwave"); + ContentIntegrityTarget target = CreateTarget("target-one", version); + LaunchContentIntegrityTargetContext context = new(target, version, isCache: false); + ContentIntegrityReport issueReport = new(new[] + { + CreateIssue(target.Id, target.DisplayName, ContentSourceKind.ManagedSingleFile, IntegrityIssueAction.Repair) + }); + ILaunchContentIntegrityProgressTarget progressTarget = Substitute.For(); + progressTarget.CanReportIntegrityProgress.Returns(true); + progressTarget.ActiveIntegrityVersion.Returns(version); + + ILaunchContentIntegrityResolutionService resolutionService = + Substitute.For(); + resolutionService.VerifyAsync( + Arg.Any(), + Arg.Any()) + .Returns( + Task.FromResult(CreateVerificationResult(issueReport, new[] { context })), + Task.FromResult(CreateVerificationResult())); + resolutionService.InitializeUntrackedManagedCachesAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(false)); + resolutionService.ResolveAsync( + Arg.Any(), + Arg.Any>(), + Arg.Any()) + .Returns(callInfo => + { + IProgress progress = + callInfo.ArgAt>(1); + progress.Report(LaunchContentIntegrityResolutionProgress.Package( + target.Id, + new PackageUpdateProgress(10485760, 5242880, 50, "package.big"))); + progress.Report(LaunchContentIntegrityResolutionProgress.Complete(target.Id)); + return Task.CompletedTask; + }); + + ILauncherDialogService dialogService = Substitute.For(); + dialogService.ShowIntegrityReview( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(true); + LauncherPackageActivityService packageActivityService = new(); + List reportedPackageActivityProgress = new(); + packageActivityService.ActivityChanged += (_, _) => + reportedPackageActivityProgress.Add(packageActivityService.ProgressPercentage); + LaunchContentIntegrityCoordinator coordinator = CreateCoordinator( + resolutionService, + packageActivityService: packageActivityService, + dialogService: dialogService); + Window owner = new(); + + // Act + bool result = await coordinator.EnsureReadyToLaunchAsync( + new[] { version }, + new[] { progressTarget }, + owner); + + // Assert + result.Should().BeTrue(); + packageActivityService.IsActive.Should().BeFalse(); + reportedPackageActivityProgress.Should().Contain(50D); + progressTarget.Received(1).BeginIntegrityProgress("Preparing"); + progressTarget.Received().ReportIntegrityProgress("Downloaded 5.0 MB of 10.0 MB", 50); + progressTarget.Received().ReportIntegrityProgress("Unpacking", 100); + progressTarget.Received(1).CompleteIntegrityProgress(); + await resolutionService.Received(1).ResolveAsync( + Arg.Is(request => + request.Report == issueReport && + request.TargetContexts.Count == 1 && + request.TargetContexts[0] == context), + Arg.Any>(), + Arg.Any()); + }); + } + + [Fact] + public void EnsureReadyToLaunchAsyncWhenManagedCachesAreInitializedRetriesVerification() + { + StaTestRunner.Run(async () => + { + // Arrange + ContentIntegrityReport issueReport = new(new[] + { + CreateIssue( + "managed-target", + "Managed", + ContentSourceKind.ManagedSingleFile, + IntegrityIssueAction.Repair) + }); + ILaunchContentIntegrityResolutionService resolutionService = + Substitute.For(); + resolutionService.VerifyAsync( + Arg.Any(), + Arg.Any()) + .Returns( + Task.FromResult(CreateVerificationResult(issueReport)), + Task.FromResult(CreateVerificationResult())); + resolutionService.InitializeUntrackedManagedCachesAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(true)); + ILauncherDialogService dialogService = Substitute.For(); + LaunchContentIntegrityCoordinator coordinator = CreateCoordinator( + resolutionService, + dialogService: dialogService); + + // Act + bool result = await coordinator.EnsureReadyToLaunchAsync( + Array.Empty(), + Array.Empty(), + new Window()); + + // Assert + result.Should().BeTrue(); + await resolutionService.Received(2).VerifyAsync( + Arg.Any(), + Arg.Any()); + await resolutionService.Received(1).InitializeUntrackedManagedCachesAsync( + Arg.Any(), + Arg.Any()); + dialogService.DidNotReceive().ShowIntegrityReview( + Arg.Any(), + Arg.Any(), + Arg.Any()); + }); + } + + [Fact] + public void EnsureReadyToLaunchAsyncWhenPackageActivityIsBusyShowsBlockedReview() + { + StaTestRunner.Run(async () => + { + // Arrange + ContentIntegrityReport issueReport = new(new[] + { + CreateIssue( + "managed-target", + "Managed", + ContentSourceKind.ManagedSingleFile, + IntegrityIssueAction.Repair) + }); + ILaunchContentIntegrityResolutionService resolutionService = + Substitute.For(); + resolutionService.VerifyAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(CreateVerificationResult(issueReport))); + resolutionService.InitializeUntrackedManagedCachesAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(false)); + ILauncherDialogService dialogService = Substitute.For(); + dialogService.ShowIntegrityReview( + issueReport, + Arg.Any(), + Arg.Any()) + .Returns(true); + LauncherPackageActivityService packageActivityService = new(); + packageActivityService.TryBegin( + "Existing", + out LauncherPackageActivityService.LauncherPackageActivityLease? lease) + .Should() + .BeTrue(); + LaunchContentIntegrityCoordinator coordinator = CreateCoordinator( + resolutionService, + packageActivityService: packageActivityService, + dialogService: dialogService); + + try + { + // Act + bool result = await coordinator.EnsureReadyToLaunchAsync( + Array.Empty(), + Array.Empty(), + new Window()); + + // Assert + result.Should().BeFalse(); + dialogService.Received(1).ShowIntegrityReview( + Arg.Is(report => + report.HasBlockingIssues && + report.Issues[0].Message == "Existing"), + Arg.Any(), + Arg.Any()); + await resolutionService.DidNotReceive().ResolveAsync( + Arg.Any(), + Arg.Any>(), + Arg.Any()); + } + finally + { + lease?.Dispose(); + } + }); + } + + [Fact] + public void EnsureReadyToLaunchAsyncWhenBlockingReportIsConfirmedStillStopsLaunch() + { + StaTestRunner.Run(async () => + { + // Arrange + ContentIntegrityReport blockingReport = new(new[] + { + new ContentIntegrityIssue( + "blocking", + "Blocking", + ContentSourceKind.UnknownLegacy, + IntegrityIssueKind.VerificationError, + IntegrityIssueAction.Block, + ".", + "blocked") + }); + ILaunchContentIntegrityResolutionService resolutionService = + Substitute.For(); + resolutionService.VerifyAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(CreateVerificationResult(blockingReport))); + resolutionService.InitializeUntrackedManagedCachesAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(false)); + ILauncherDialogService dialogService = Substitute.For(); + dialogService.ShowIntegrityReview( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(true); + LaunchContentIntegrityCoordinator coordinator = CreateCoordinator( + resolutionService, + dialogService: dialogService); + + // Act + bool result = await coordinator.EnsureReadyToLaunchAsync( + Array.Empty(), + Array.Empty(), + new Window()); + + // Assert + result.Should().BeFalse(); + await resolutionService.DidNotReceive().ResolveAsync( + Arg.Any(), + Arg.Any>(), + Arg.Any()); + }); + } + + [Fact] + public void EnsureReadyToLaunchAsyncWhenUnknownLegacyIssuesExistReviewsOnlyLegacyIssues() + { + StaTestRunner.Run(async () => + { + // Arrange + ContentIntegrityReport report = new(new[] + { + CreateIssue("manual-target", "Manual", ContentSourceKind.Manual, IntegrityIssueAction.Absorb), + CreateIssue("legacy-target", "Legacy", ContentSourceKind.UnknownLegacy, IntegrityIssueAction.TrustAsManual), + }); + ILaunchContentIntegrityResolutionService resolutionService = + Substitute.For(); + resolutionService.VerifyAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(CreateVerificationResult(report))); + resolutionService.InitializeUntrackedManagedCachesAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(false)); + ILauncherDialogService dialogService = Substitute.For(); + dialogService.ShowIntegrityReview( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(false); + LaunchContentIntegrityCoordinator coordinator = CreateCoordinator( + resolutionService, + dialogService: dialogService); + Window owner = new(); + + // Act + bool result = await coordinator.EnsureReadyToLaunchAsync( + Array.Empty(), + Array.Empty(), + owner); + + // Assert + result.Should().BeFalse(); + dialogService.Received(1).ShowIntegrityReview( + Arg.Is(reviewReport => + reviewReport.Issues.Count == 1 && + reviewReport.Issues[0].TargetId == "legacy-target"), + Arg.Is(options => + options.PrimaryActionText == "Trust as manual" && + options.Description == "Trust Legacy"), + owner); + await resolutionService.DidNotReceive().ResolveAsync( + Arg.Any(), + Arg.Any>(), + Arg.Any()); + }); + } + + [Fact] + public void EnsureReadyToLaunchAsyncWhenVerificationThrowsShowsBlockedReview() + { + StaTestRunner.Run(async () => + { + // Arrange + ILaunchContentIntegrityResolutionService resolutionService = + Substitute.For(); + resolutionService.VerifyAsync( + Arg.Any(), + Arg.Any()) + .Returns>(_ => + throw new InvalidOperationException("verification failed")); + ILauncherDialogService dialogService = Substitute.For(); + LaunchContentIntegrityCoordinator coordinator = CreateCoordinator( + resolutionService, + dialogService: dialogService); + Window owner = new(); + + // Act + bool result = await coordinator.EnsureReadyToLaunchAsync( + Array.Empty(), + Array.Empty(), + owner); + + // Assert + result.Should().BeFalse(); + dialogService.Received(1).ShowIntegrityReview( + Arg.Is(report => + report.HasBlockingIssues && + report.Issues[0].Message == "verification failed"), + Arg.Is(options => + options.Description == "Remove blocked entries or restore content" && + options.PrimaryActionText == "Cancel launch"), + owner); + }); + } + + [Fact] + public void SnapshotMethodsCallResolutionServiceOnlyForMatchingSourceKinds() + { + StaTestRunner.Run(async () => + { + // Arrange + ILaunchContentIntegrityResolutionService resolutionService = + Substitute.For(); + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + ModificationVersion allVersion = CreateVersion(ContentSourceKind.Manual, "Catalog"); + catalogQueries.GetAllModsVersionsList().Returns(new[] { allVersion }); + LaunchContentIntegrityCoordinator coordinator = CreateCoordinator( + resolutionService, + catalogQueries); + ModificationVersion manualVersion = CreateVersion(ContentSourceKind.Manual, "Manual"); + ModificationVersion managedVersion = CreateVersion(ContentSourceKind.ManagedSingleFile, "Managed"); + managedVersion.SimpleDownloadLink = "https://example.test/package.zip"; + + // Act + await coordinator.RegisterManualImportAsync(manualVersion); + await coordinator.RegisterManualImportAsync(null!); + await coordinator.CaptureManagedInstallSnapshotAsync(managedVersion); + await coordinator.CaptureManagedInstallSnapshotAsync(manualVersion); + await coordinator.CaptureManualImageSnapshotAsync(manualVersion); + await coordinator.CaptureManualImageSnapshotAsync(managedVersion); + + // Assert + await resolutionService.Received(1).RegisterManualImportAsync( + Arg.Is(request => + ((LaunchContentIntegrityVersionRequest)request).Version == manualVersion && + ((LaunchContentIntegrityVersionRequest)request).AllVersions.Any(version => version == allVersion)), + Arg.Any()); + await resolutionService.Received(1).CaptureManagedInstallSnapshotAsync( + Arg.Is(request => + ((LaunchContentIntegrityVersionRequest)request).Version == managedVersion), + Arg.Any()); + await resolutionService.Received(1).CaptureManualImageSnapshotAsync( + Arg.Is(request => + ((LaunchContentIntegrityVersionRequest)request).Version == manualVersion), + Arg.Any()); + }); + } + + [Fact] + public void SnapshotMethodsSwallowResolutionExceptions() + { + StaTestRunner.Run(async () => + { + // Arrange + ILaunchContentIntegrityResolutionService resolutionService = + Substitute.For(); + resolutionService.RegisterManualImportAsync( + Arg.Any(), + Arg.Any()) + .Returns(_ => throw new InvalidOperationException("manual failed")); + resolutionService.CaptureManagedInstallSnapshotAsync( + Arg.Any(), + Arg.Any()) + .Returns(_ => throw new InvalidOperationException("managed failed")); + resolutionService.CaptureManualImageSnapshotAsync( + Arg.Any(), + Arg.Any()) + .Returns(_ => throw new InvalidOperationException("image failed")); + LaunchContentIntegrityCoordinator coordinator = CreateCoordinator(resolutionService); + ModificationVersion manualVersion = CreateVersion(ContentSourceKind.Manual, "Manual"); + ModificationVersion managedVersion = CreateVersion(ContentSourceKind.ManagedSingleFile, "Managed"); + managedVersion.SimpleDownloadLink = "https://example.test/package.zip"; + + // Act + Func act = async () => + { + await coordinator.RegisterManualImportAsync(manualVersion); + await coordinator.CaptureManagedInstallSnapshotAsync(managedVersion); + await coordinator.CaptureManualImageSnapshotAsync(manualVersion); + }; + + // Assert + await act.Should().NotThrowAsync(); + }); + } + + private static LaunchContentIntegrityCoordinator CreateCoordinator( + ILaunchContentIntegrityResolutionService? resolutionService = null, + ILauncherContentCatalogQueries? catalogQueries = null, + LauncherPackageActivityService? packageActivityService = null, + ILauncherDialogService? dialogService = null, + ILauncherStringLocalizer? stringLocalizer = null) + { + return new LaunchContentIntegrityCoordinator( + resolutionService ?? Substitute.For(), + catalogQueries ?? CreateCatalogQueries(), + CreatePaths(), + new LauncherContentLayout("Addons", "Patches"), + packageActivityService ?? new LauncherPackageActivityService(), + stringLocalizer ?? CreateStringLocalizer(), + dialogService ?? Substitute.For(), + NullLogger.Instance); + } + + private static ILauncherContentCatalogQueries CreateCatalogQueries() + { + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetAllModsVersionsList().Returns(Array.Empty()); + return catalogQueries; + } + + private static ILauncherStringLocalizer CreateStringLocalizer() + { + return new TestStringLocalizer(new Dictionary + { + ["AbsorbAndLaunch"] = "Absorb and launch", + ["CancelLaunch"] = "Cancel launch", + ["DownloadInProgress"] = "Downloaded {0} MB of {1} MB", + ["FixAbsorbAndLaunch"] = "Fix, absorb, and launch", + ["FixAndLaunch"] = "Fix and launch", + ["IntegrityAbsorbGroup"] = "Absorb", + ["IntegrityBlockGroup"] = "Blocked", + ["IntegrityBlockedDescription"] = "Remove blocked entries or restore content", + ["IntegrityCacheSuffix"] = " cache", + ["IntegrityDeleteGroup"] = "Delete", + ["IntegrityDialogTitle"] = "Integrity", + ["IntegrityIssueEmptyDirectoryLabel"] = "Empty folder", + ["IntegrityIssueGroupSummaryMultiple"] = "{0} changes", + ["IntegrityIssueGroupSummarySingle"] = "{0} change", + ["IntegrityIssueMissingFileLabel"] = "Missing", + ["IntegrityIssueModifiedFileLabel"] = "Modified", + ["IntegrityIssueUnexpectedFileLabel"] = "Added", + ["IntegrityIssueUnsafeLinkLabel"] = "Unsafe link", + ["IntegrityIssueUntrackedLabel"] = "Untracked", + ["IntegrityIssueVerificationErrorLabel"] = "Verification error", + ["IntegrityLegacyDescription"] = "Trust {0}", + ["IntegrityManagedDescription"] = "Repair {0}", + ["IntegrityManualDescription"] = "Absorb {0}", + ["IntegrityMixedDescription"] = "Repair {0}; absorb {1}", + ["IntegrityRepairGroup"] = "Repair", + ["IntegrityRepeatedFailure"] = "Repeated failure", + ["IntegrityRedownloadGroup"] = "Redownload", + ["IntegrityTrustGroup"] = "Trust", + ["LaunchVerificationRunning"] = "Launch verification", + ["Preparing"] = "Preparing", + ["TrustAsManual"] = "Trust as manual", + ["UnpackingPreparing"] = "Unpacking", + }); + } + + private static LaunchContentIntegrityVerificationResult CreateVerificationResult( + ContentIntegrityReport? report = null, + IReadOnlyList? contexts = null) + { + return new LaunchContentIntegrityVerificationResult( + report ?? new ContentIntegrityReport(Array.Empty()), + contexts ?? Array.Empty()); + } + + private static ContentIntegrityIssue CreateIssue( + string targetId, + string targetName, + ContentSourceKind sourceKind, + IntegrityIssueAction action) + { + return new ContentIntegrityIssue( + targetId, + targetName, + sourceKind, + IntegrityIssueKind.ModifiedFile, + action, + "Data/file.big"); + } + + private static ContentIntegrityTarget CreateTarget( + string targetId, + ModificationVersion version) + { + return new ContentIntegrityTarget( + targetId, + version.DisplayName, + @"C:\Games\ZeroHour\GenLauncherGO\Mods\Shockwave\1.0", + version.EffectiveContentSourceKind, + new HashSet(StringComparer.OrdinalIgnoreCase)); + } + + private static ModificationVersion CreateVersion( + ContentSourceKind sourceKind, + string name = "Shockwave") + { + return new ModificationVersion + { + Name = name, + Version = "1.0", + ModificationType = ModificationType.Mod, + ContentSourceKind = sourceKind + }; + } + + private static LauncherPaths CreatePaths() + { + return new LauncherPaths( + @"C:\Games\ZeroHour", + @"C:\Games\ZeroHour\GenLauncherGO", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache\Images", + @"C:\Games\ZeroHour\GenLauncherGO\Mods", + @"C:\Games\ZeroHour\GenLauncherGO\Logs", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Temp", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Deployment"); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Integrity/LauncherPackageActivityServiceTests.cs b/GenLauncherGO.Tests/UI/Features/Integrity/LauncherPackageActivityServiceTests.cs new file mode 100644 index 00000000..6edd26b2 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Integrity/LauncherPackageActivityServiceTests.cs @@ -0,0 +1,83 @@ +using GenLauncherGO.UI.Features.Integrity; + +namespace GenLauncherGO.Tests.UI.Features.Integrity; + +public sealed class LauncherPackageActivityServiceTests +{ + [Fact] + public void TryBegin_WhenActivityIsAlreadyActive_RejectsSecondActivityUntilLeaseIsDisposed() + { + // Arrange + var service = new LauncherPackageActivityService(); + + // Act + bool firstStarted = service.TryBegin( + "First", + out LauncherPackageActivityService.LauncherPackageActivityLease? firstLease); + bool secondStarted = service.TryBegin( + "Second", + out LauncherPackageActivityService.LauncherPackageActivityLease? secondLease); + firstLease?.Dispose(); + bool thirdStarted = service.TryBegin( + "Third", + out LauncherPackageActivityService.LauncherPackageActivityLease? thirdLease); + + // Assert + firstStarted.Should().BeTrue(); + secondStarted.Should().BeFalse(); + secondLease.Should().BeNull(); + thirdStarted.Should().BeTrue(); + thirdLease.Should().NotBeNull(); + + thirdLease?.Dispose(); + } + + [Fact] + public void ReportProgress_WhenActivityIsActive_ClampsProgressAndRaisesChange() + { + // Arrange + var service = new LauncherPackageActivityService(); + int changeCount = 0; + service.ActivityChanged += (_, _) => changeCount++; + service.TryBegin( + "Download", + out LauncherPackageActivityService.LauncherPackageActivityLease? lease) + .Should() + .BeTrue(); + + try + { + // Act + service.ReportProgress(125D); + + // Assert + service.ProgressPercentage.Should().Be(100D); + changeCount.Should().Be(2); + } + finally + { + lease?.Dispose(); + } + } + + [Fact] + public void LeaseDispose_WhenActivityIsActive_ClearsActivityAndProgress() + { + // Arrange + var service = new LauncherPackageActivityService(); + service.TryBegin( + "Download", + out LauncherPackageActivityService.LauncherPackageActivityLease? lease) + .Should() + .BeTrue(); + service.ReportProgress(50D); + + // Act + lease?.Dispose(); + + // Assert + service.IsActive.Should().BeFalse(); + service.ActiveDisplayName.Should().BeEmpty(); + service.ProgressPercentage.Should().BeNull(); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Integrity/ViewModels/IntegrityReviewViewModelTests.cs b/GenLauncherGO.Tests/UI/Features/Integrity/ViewModels/IntegrityReviewViewModelTests.cs new file mode 100644 index 00000000..67c09d28 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Integrity/ViewModels/IntegrityReviewViewModelTests.cs @@ -0,0 +1,168 @@ +using System.Collections.Generic; +using System.Windows; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Integrity.ViewModels; + +namespace GenLauncherGO.Tests.UI.Features.Integrity.ViewModels; + +public sealed class IntegrityReviewViewModelTests +{ + [Fact] + public void Constructor_GroupsIssuesByTargetAndMapsEntries() + { + // Arrange + ContentIntegrityIssue modifiedIssue = new( + "mod:one", + "Demo Mod", + ContentSourceKind.ManagedS3, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.Repair, + "Data/file.big", + "Hash mismatch", + ExpectedSizeBytes: 2048); + ContentIntegrityIssue addedIssue = new( + "mod:one", + "Demo Mod", + ContentSourceKind.ManagedS3, + IntegrityIssueKind.UnexpectedFile, + IntegrityIssueAction.Delete, + "Data/debug.txt"); + + // Act + IntegrityReviewViewModel viewModel = new( + new ContentIntegrityReport(new[] { modifiedIssue, addedIssue }), + CreateOptions()); + + // Assert + viewModel.IssueGroups.Should().ContainSingle(); + IntegrityReviewGroup group = viewModel.IssueGroups[0]; + group.TargetDisplayName.Should().Be("Demo Mod"); + group.Summary.Should().Be("2 changes"); + group.Entries.Should().Equal( + new IntegrityReviewEntry( + "Modified", + "Data/file.big (2.0 KB) - Hash mismatch", + "Repair"), + new IntegrityReviewEntry( + "Added", + "Data/debug.txt", + "Delete")); + } + + [Fact] + public void Constructor_CreatesSeparateGroupsForSeparateTargets() + { + // Arrange + ContentIntegrityIssue firstIssue = new( + "mod:one", + "Demo Mod", + ContentSourceKind.ManagedS3, + IntegrityIssueKind.MissingFile, + IntegrityIssueAction.Repair, + "Data/file.big"); + ContentIntegrityIssue secondIssue = new( + "addon:one", + "Demo Addon", + ContentSourceKind.Manual, + IntegrityIssueKind.UnexpectedFile, + IntegrityIssueAction.Absorb, + "Data/addon.gib"); + + // Act + IntegrityReviewViewModel viewModel = new( + new ContentIntegrityReport(new[] { firstIssue, secondIssue }), + CreateOptions()); + + // Assert + viewModel.IssueGroups.Should().HaveCount(2); + viewModel.IssueGroups[0].TargetDisplayName.Should().Be("Demo Mod"); + viewModel.IssueGroups[0].Summary.Should().Be("1 change"); + viewModel.IssueGroups[1].TargetDisplayName.Should().Be("Demo Addon"); + viewModel.IssueGroups[1].Entries.Should().ContainSingle().Which.ActionLabel.Should().Be("Absorb"); + } + + [Fact] + public void ConfirmResolutionCommand_RaisesCloseRequestAndMarksResolutionConfirmed() + { + // Arrange + IntegrityReviewViewModel viewModel = new( + new ContentIntegrityReport(System.Array.Empty()), + CreateOptions()); + bool closeRequested = false; + viewModel.CloseRequested += (_, _) => closeRequested = true; + + // Act + viewModel.ConfirmResolutionCommand.Execute(null); + + // Assert + closeRequested.Should().BeTrue(); + viewModel.ResolutionConfirmed.Should().BeTrue(); + } + + [Fact] + public void CancelCommand_RaisesCloseRequestWithoutConfirmingResolution() + { + // Arrange + IntegrityReviewViewModel viewModel = new( + new ContentIntegrityReport(System.Array.Empty()), + CreateOptions()); + bool closeRequested = false; + viewModel.CloseRequested += (_, _) => closeRequested = true; + + // Act + viewModel.CancelCommand.Execute(null); + + // Assert + closeRequested.Should().BeTrue(); + viewModel.ResolutionConfirmed.Should().BeFalse(); + } + + [Fact] + public void Constructor_ShowsPrimaryActionWhenItDiffersFromCancelAction() + { + // Arrange, Act + IntegrityReviewViewModel viewModel = new( + new ContentIntegrityReport(System.Array.Empty()), + CreateOptions()); + + // Assert + viewModel.PrimaryActionVisibility.Should().Be(Visibility.Visible); + } + + [Fact] + public void Constructor_HidesPrimaryActionWhenItDuplicatesCancelAction() + { + // Arrange, Act + IntegrityReviewViewModel viewModel = new( + new ContentIntegrityReport(System.Array.Empty()), + CreateOptions(primaryActionText: "Cancel")); + + // Assert + viewModel.PrimaryActionVisibility.Should().Be(Visibility.Collapsed); + } + + private static IntegrityReviewDialogOptions CreateOptions(string primaryActionText = "Apply") + { + return new IntegrityReviewDialogOptions( + "Review", + "Description", + primaryActionText, + "Cancel", + new Dictionary + { + [IntegrityIssueAction.Absorb] = "Absorb", + [IntegrityIssueAction.Delete] = "Delete", + [IntegrityIssueAction.Repair] = "Repair", + [IntegrityIssueAction.Redownload] = "Redownload" + }, + new Dictionary + { + [IntegrityIssueKind.MissingFile] = "Missing", + [IntegrityIssueKind.ModifiedFile] = "Modified", + [IntegrityIssueKind.UnexpectedFile] = "Added", + }, + "{0} change", + "{0} changes"); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/LauncherUiServiceCollectionExtensionsTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/LauncherUiServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..e04deca6 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/LauncherUiServiceCollectionExtensionsTests.cs @@ -0,0 +1,341 @@ +using System; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Launcher.Composition; +using GenLauncherGO.UI.Features.Launcher.Contracts; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Launcher.Support; +using GenLauncherGO.UI.Features.Launcher.ViewModels; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Tests.UI.Features.Launcher; + +public sealed class LauncherUiServiceCollectionExtensionsTests +{ + [Fact] + public void AddGenLauncherGoLauncherUiReturnsSameServiceCollection() + { + // Arrange + ServiceCollection services = new(); + + // Act + IServiceCollection returnedServices = services.AddGenLauncherGoLauncherUi(); + + // Assert + returnedServices.Should().BeSameAs(services); + } + + [Fact] + public void AddGenLauncherGoLauncherUiThrowsForNullServices() + { + // Arrange + ServiceCollection? services = null; + + // Act + Action act = () => services!.AddGenLauncherGoLauncherUi(); + + // Assert + act.Should().Throw() + .WithParameterName(nameof(services)); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersLaunchCoordinator() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherLaunchCoordinator) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersManualImportCoordinator() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherManualImportCoordinator) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersLaunchReadinessCoordinator() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherLaunchReadinessCoordinator) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersSelectedContentService() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherSelectedContentService) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersContentListService() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherContentListService) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersTabStateService() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherTabStateService) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersContentViewStateService() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherContentViewStateService) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersTileActionService() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherTileActionService) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersTileVersionSelectionService() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherTileVersionSelectionService) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersExecutableSelectionService() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherExecutableSelectionService) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersVisualThemeService() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherVisualThemeService) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersPackageActivityService() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherPackageActivityService) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersFilePicker() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(ILauncherFilePicker) && + descriptor.ImplementationType == typeof(WpfLauncherFilePicker) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersDownloadCoordinator() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherModificationDownloadCoordinator) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersShellNavigationCoordinator() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherShellNavigationCoordinator) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersWindowWorkflowCoordinator() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherWindowWorkflowCoordinator) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersDragDropController() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherDragDropController) && + descriptor.Lifetime == ServiceLifetime.Transient); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersSelectionControllerFactory() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherSelectionControllerFactory) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersWindowListControllerFactory() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(LauncherWindowListControllerFactory) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoLauncherUiRegistersMainWindowViewModel() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(MainWindowViewModel) && + descriptor.Lifetime == ServiceLifetime.Transient); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Models/LauncherLaunchModelTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Models/LauncherLaunchModelTests.cs new file mode 100644 index 00000000..658efd28 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Models/LauncherLaunchModelTests.cs @@ -0,0 +1,156 @@ +using System; +using System.Collections.Generic; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Windows; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Launcher.Models; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Models; + +public sealed class LauncherLaunchModelTests +{ + [Fact] + public void LauncherLaunchRequestConstructorStoresValues() + { + RunOnStaThread(() => + { + // Arrange + ModificationVersion[] versions = { new ModificationVersion { Name = "ShockWave", Version = "1.0" } }; + ILaunchContentIntegrityProgressTarget[] progressTargets = + { + Substitute.For() + }; + Window owner = new(); + bool disabled = false; + bool enabled = false; + Action disableUi = () => disabled = true; + Action enableUi = () => enabled = true; + + // Act + LauncherLaunchRequest request = new( + LauncherLaunchTargetKind.GameClient, + "generals.exe", + true, + versions, + progressTargets, + owner, + disableUi, + enableUi); + request.DisableUi(); + request.EnableUi(); + + // Assert + request.TargetKind.Should().Be(LauncherLaunchTargetKind.GameClient); + request.ExecutableName.Should().Be("generals.exe"); + request.UseGeneralsOnline.Should().BeTrue(); + request.ActiveVersions.Should().BeSameAs(versions); + request.ActiveProgressTargets.Should().BeSameAs(progressTargets); + request.Owner.Should().BeSameAs(owner); + disabled.Should().BeTrue(); + enabled.Should().BeTrue(); + }); + } + + [Fact] + public void LauncherLaunchRequestConstructorNormalizesNullExecutableName() + { + RunOnStaThread(() => + { + // Arrange + Window owner = new(); + + // Act + LauncherLaunchRequest request = new( + LauncherLaunchTargetKind.WorldBuilder, + null!, + false, + Array.Empty(), + Array.Empty(), + owner, + () => { }, + () => { }); + + // Assert + request.ExecutableName.Should().BeEmpty(); + }); + } + + [Fact] + public void LauncherLaunchResultFactoriesCreateExpectedStates() + { + // Arrange and Act + var stopped = LauncherLaunchResult.Stopped(LauncherLaunchFailureKind.PreparationFailed); + var attempted = LauncherLaunchResult.Attempted(processSucceeded: true); + + // Assert + stopped.LaunchStarted.Should().BeFalse(); + stopped.ProcessSucceeded.Should().BeFalse(); + stopped.FailureKind.Should().Be(LauncherLaunchFailureKind.PreparationFailed); + attempted.LaunchStarted.Should().BeTrue(); + attempted.ProcessSucceeded.Should().BeTrue(); + attempted.FailureKind.Should().Be(LauncherLaunchFailureKind.None); + } + + [Fact] + public void ExecutableOptionStoresValuesAndUsesDisplayNameForText() + { + // Act + ExecutableOption option = new( + "World Builder", + "worldbuilder.exe", + GenLauncherGO.Core.Launching.Models.WorldBuilderExecutableKind.Community, + isAvailable: false); + + // Assert + option.DisplayName.Should().Be("World Builder"); + option.ExecutableName.Should().Be("worldbuilder.exe"); + option.Kind.Should().Be(GenLauncherGO.Core.Launching.Models.WorldBuilderExecutableKind.Community); + option.IsAvailable.Should().BeFalse(); + option.ToString().Should().Be("World Builder"); + } + + [Theory] + [InlineData(GenLauncherGO.Core.Launching.Models.GameClientExecutableKind.GeneralsOnline, true)] + [InlineData(GenLauncherGO.Core.Launching.Models.GameClientExecutableKind.Community, false)] + public void GameClientOptionReportsGeneralsOnlineState( + GenLauncherGO.Core.Launching.Models.GameClientExecutableKind kind, + bool expectedGeneralsOnline) + { + // Act + GameClientOption option = new("Game Client", "generals.exe", kind); + + // Assert + option.DisplayName.Should().Be("Game Client"); + option.ExecutableName.Should().Be("generals.exe"); + option.Kind.Should().Be(kind); + option.IsGeneralsOnline.Should().Be(expectedGeneralsOnline); + option.ToString().Should().Be("Game Client"); + } + + private static void RunOnStaThread(Action action) + { + Exception? exception = null; + Thread thread = new(() => + { + try + { + action(); + } + catch (Exception caughtException) + { + exception = caughtException; + } + }); + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + + if (exception is not null) + { + ExceptionDispatchInfo.Capture(exception).Throw(); + } + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherContentListServiceTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherContentListServiceTests.cs new file mode 100644 index 00000000..cee9e6b9 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherContentListServiceTests.cs @@ -0,0 +1,345 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Mods; +using GenLauncherGO.UI.Features.Startup; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Services; + +public sealed class LauncherContentListServiceTests +{ + [Fact] + public void GetModificationsForDisplayOrdersModsBySavedListNumber() + { + // Arrange + GameModification second = CreateModification("Second", numberInList: 2); + GameModification first = CreateModification("First", numberInList: 1); + ILauncherContentCatalogQueries queries = Substitute.For(); + queries.GetMods().Returns(new[] { second, first }); + LauncherContentListService service = CreateService(queries, connected: false); + + // Act + IReadOnlyList result = service.GetModificationsForDisplay(); + + // Assert + result.Select(modification => modification.Name) + .Should() + .Equal("First", "Second"); + } + + [Fact] + public void GetModificationsForDisplayAddsAdvertisingFirstWhenConnectedAndMissing() + { + // Arrange + GameModification first = CreateModification("First", numberInList: 1); + GameModification second = CreateModification("Second", numberInList: 2); + GameModification third = CreateModification("Third", numberInList: 3); + GameModification advertisingCard = CreateModification("Advertising", ModificationType.Advertising); + ModificationVersion advertisingVersion = CreateVersion("Advertising", ModificationType.Advertising); + ILauncherContentCatalogQueries queries = Substitute.For(); + ILauncherContentCatalogCommands commands = Substitute.For(); + queries.GetMods().Returns( + new[] { third, first, second }, + new[] { advertisingCard, first, second, third }); + queries.GetAdvertising().Returns(advertisingVersion); + LauncherContentListService service = CreateService(queries, commands, connected: true); + + // Act + IReadOnlyList result = service.GetModificationsForDisplay(); + + // Assert + result.Select(modification => modification.Name) + .Should() + .Equal("Advertising", "First", "Second", "Third"); + commands.Received(1).AddModModification(advertisingVersion); + } + + [Fact] + public void GetModificationsForDisplayRefreshesExistingAdvertisingWhenConnected() + { + // Arrange + GameModification advertisingCard = CreateModification("Advertising", ModificationType.Advertising); + GameModification mod = CreateModification("Mod", numberInList: 1); + ModificationVersion advertisingVersion = CreateVersion("Advertising", ModificationType.Advertising); + ILauncherContentCatalogQueries queries = Substitute.For(); + ILauncherContentCatalogCommands commands = Substitute.For(); + queries.GetMods().Returns(new[] { mod, advertisingCard }); + queries.GetAdvertising().Returns(advertisingVersion); + LauncherContentListService service = CreateService(queries, commands, connected: true); + + // Act + IReadOnlyList result = service.GetModificationsForDisplay(); + + // Assert + result.Should().Equal(advertisingCard, mod); + commands.Received(1).AddModModification(advertisingVersion); + } + + [Fact] + public void GetModificationsForDisplayDoesNotAddAdvertisingWhenDisconnected() + { + // Arrange + GameModification first = CreateModification("First", numberInList: 1); + GameModification second = CreateModification("Second", numberInList: 2); + GameModification third = CreateModification("Third", numberInList: 3); + ILauncherContentCatalogQueries queries = Substitute.For(); + ILauncherContentCatalogCommands commands = Substitute.For(); + queries.GetMods().Returns(new[] { first, second, third }); + LauncherContentListService service = CreateService(queries, commands, connected: false); + + // Act + IReadOnlyList result = service.GetModificationsForDisplay(); + + // Assert + result.Should().Equal(first, second, third); + commands.DidNotReceiveWithAnyArgs().AddModModification(default!); + } + + [Fact] + public void GetPatchesForDisplayReturnsSelectedModPatches() + { + // Arrange + GameModification patch = CreateModification("Patch", ModificationType.Patch); + ILauncherContentCatalogQueries queries = Substitute.For(); + queries.GetPatchesForSelectedMod().Returns(new[] { patch }); + LauncherContentListService service = CreateService(queries, connected: false); + + // Act + IReadOnlyList result = service.GetPatchesForDisplay(); + + // Assert + result.Should().Equal(patch); + } + + [Fact] + public void GetAddonsForDisplayReturnsSelectedModAddons() + { + // Arrange + GameModification addon = CreateModification("Addon", ModificationType.Addon); + ILauncherContentCatalogQueries queries = Substitute.For(); + queries.GetAddonsForSelectedMod().Returns(new[] { addon }); + LauncherContentListService service = CreateService(queries, connected: false); + + // Act + IReadOnlyList result = service.GetAddonsForDisplay(); + + // Assert + result.Should().Equal(addon); + } + + [Fact] + public void MoveModificationInListMovesSourceAfterTargetWhenSourceStartsBeforeTarget() + { + // Arrange + ModificationViewModel first = CreateViewModel("First"); + ModificationViewModel second = CreateViewModel("Second"); + ModificationViewModel third = CreateViewModel("Third"); + ObservableCollection list = new() { first, second, third }; + + // Act + LauncherContentListService.MoveModificationInList(list, first, sourceIndex: 0, targetIndex: 2); + + // Assert + list.Should().Equal(second, third, first); + } + + [Fact] + public void MoveModificationInListMovesSourceBeforeTargetWhenSourceStartsAfterTarget() + { + // Arrange + ModificationViewModel first = CreateViewModel("First"); + ModificationViewModel second = CreateViewModel("Second"); + ModificationViewModel third = CreateViewModel("Third"); + ObservableCollection list = new() { first, second, third }; + + // Act + LauncherContentListService.MoveModificationInList(list, third, sourceIndex: 2, targetIndex: 0); + + // Assert + list.Should().Equal(third, first, second); + } + + [Fact] + public void SetIndexNumbersForModsUpdatesBackingModifications() + { + // Arrange + ModificationViewModel first = CreateViewModel("First"); + ModificationViewModel second = CreateViewModel("Second"); + ObservableCollection list = new() { first, second }; + + // Act + LauncherContentListService.SetIndexNumbersForMods(list); + + // Assert + first.ContainerModification.NumberInList.Should().Be(0); + second.ContainerModification.NumberInList.Should().Be(1); + } + + [Fact] + public void RemoveModificationByNameRemovesCaseInsensitiveMatch() + { + // Arrange + ModificationViewModel first = CreateViewModel("First"); + ModificationViewModel second = CreateViewModel("Second"); + ObservableCollection list = new() { first, second }; + + // Act + LauncherContentListService.RemoveModificationByName(list, "second"); + + // Assert + list.Should().Equal(first); + } + + [Fact] + public void CreateViewModelsCreatesTileCollectionInSourceOrder() + { + // Arrange + GameModification first = CreateModification("First"); + GameModification second = CreateModification("Second"); + + // Act + ObservableCollection result = LauncherContentListService.CreateViewModels( + new[] { first, second }, + modification => CreateViewModel(modification.Name)); + + // Assert + result.Select(viewModel => viewModel.ContainerModification.Name) + .Should() + .Equal("First", "Second"); + } + + [Fact] + public void FindMatchingModificationReturnsCaseInsensitiveNameMatch() + { + // Arrange + ModificationViewModel first = CreateViewModel("First"); + ModificationViewModel second = CreateViewModel("Second"); + GameModification selected = CreateModification("second"); + + // Act + ModificationViewModel? result = LauncherContentListService.FindMatchingModification( + new[] { first, second }, + selected); + + // Assert + result.Should().BeSameAs(second); + } + + [Fact] + public void FindMatchingModificationReturnsNullWhenNoSelectionExists() + { + // Arrange + ModificationViewModel first = CreateViewModel("First"); + + // Act + ModificationViewModel? result = LauncherContentListService.FindMatchingModification( + new[] { first }, + selectedModification: null); + + // Assert + result.Should().BeNull(); + } + + [Fact] + public void FindMatchingAddonsReturnsPersistedSelectionsInDisplayOrder() + { + // Arrange + ModificationViewModel first = CreateViewModel("First"); + ModificationViewModel second = CreateViewModel("Second"); + ModificationViewModel third = CreateViewModel("Third"); + GameModification selectedThird = CreateModification("third"); + GameModification selectedFirst = CreateModification("first"); + + // Act + IReadOnlyList result = LauncherContentListService.FindMatchingAddons( + new[] { first, second, third }, + new[] { selectedThird, selectedFirst }); + + // Assert + result.Should().Equal(first, third); + } + + private static LauncherContentListService CreateService( + ILauncherContentCatalogQueries queries, + bool connected) + { + return CreateService( + queries, + Substitute.For(), + connected); + } + + private static LauncherContentListService CreateService( + ILauncherContentCatalogQueries queries, + ILauncherContentCatalogCommands commands, + bool connected) + { + LauncherRuntimeContext runtimeContext = new(CreatePaths(), "1.0") + { + Connected = connected + }; + + return new LauncherContentListService(queries, commands, runtimeContext); + } + + private static GameModification CreateModification( + string name, + ModificationType modificationType = ModificationType.Mod, + int numberInList = 0) + { + return new GameModification + { + Name = name, + ModificationType = modificationType, + NumberInList = numberInList, + ModificationVersions = new List + { + CreateVersion(name, modificationType) + } + }; + } + + private static ModificationVersion CreateVersion( + string name, + ModificationType modificationType = ModificationType.Mod) + { + return new ModificationVersion + { + Name = name, + Version = "1.0", + ModificationType = modificationType + }; + } + + private static ModificationViewModel CreateViewModel(string name) + { + return new ModificationViewModel( + CreateModification(name), + new ModificationImageSourceFactory(NullLogger.Instance), + new TestLauncherModsContext(), + Substitute.For(), + new TestStringLocalizer(), + NullLogger.Instance); + } + + private static LauncherPaths CreatePaths() + { + return new LauncherPaths( + "C:\\Game", + "C:\\Game\\GenLauncherGO", + "C:\\Game\\GenLauncherGO\\Runtime", + "C:\\Game\\GenLauncherGO\\Runtime\\Cache", + "C:\\Game\\GenLauncherGO\\Runtime\\Cache\\Images", + "C:\\Game\\GenLauncherGO\\Mods", + "C:\\Game\\GenLauncherGO\\Logs", + "C:\\Game\\GenLauncherGO\\Runtime\\Temp", + "C:\\Game\\GenLauncherGO\\Runtime\\Deployment"); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherContentViewStateServiceTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherContentViewStateServiceTests.cs new file mode 100644 index 00000000..6b307359 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherContentViewStateServiceTests.cs @@ -0,0 +1,146 @@ +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.UI.Features.Launcher.Models; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Startup; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Services; + +public sealed class LauncherContentViewStateServiceTests +{ + [Fact] + public void GetStateShowsModListAndAddButtonWhenConnected() + { + // Arrange + LauncherContentViewStateService service = CreateService(connected: true); + + // Act + LauncherContentViewState result = service.GetState(LauncherContentViewKind.Modifications); + + // Assert + result.ShowModsList.Should().BeTrue(); + result.ShowManualAddMod.Should().BeTrue(); + result.ShowAddModButton.Should().BeTrue(); + result.ShowPatchesList.Should().BeFalse(); + result.ShowAddonsList.Should().BeFalse(); + result.RequiresOriginalGameContentLoad.Should().BeFalse(); + } + + [Fact] + public void GetStateHidesRemoteAddButtonWhenDisconnected() + { + // Arrange + LauncherContentViewStateService service = CreateService(connected: false); + + // Act + LauncherContentViewState result = service.GetState(LauncherContentViewKind.Modifications); + + // Assert + result.ShowModsList.Should().BeTrue(); + result.ShowManualAddMod.Should().BeTrue(); + result.ShowAddModButton.Should().BeFalse(); + } + + [Fact] + public void GetStateRequiresOriginalGameContentLoadForPatchesWhenNoModIsSelected() + { + // Arrange + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + LauncherContentViewStateService service = CreateService(catalogQueries, connected: true); + + // Act + LauncherContentViewState result = service.GetState(LauncherContentViewKind.Patches); + + // Assert + result.ShowPatchesList.Should().BeTrue(); + result.ShowManualAddPatch.Should().BeTrue(); + result.ShowModsList.Should().BeFalse(); + result.ShowAddonsList.Should().BeFalse(); + result.ShowAddModButton.Should().BeFalse(); + result.RequiresOriginalGameContentLoad.Should().BeTrue(); + } + + [Fact] + public void GetStateRequiresOriginalGameContentLoadForAddonsWhenNoModIsSelected() + { + // Arrange + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + LauncherContentViewStateService service = CreateService(catalogQueries, connected: true); + + // Act + LauncherContentViewState result = service.GetState(LauncherContentViewKind.Addons); + + // Assert + result.ShowAddonsList.Should().BeTrue(); + result.ShowManualAddAddon.Should().BeTrue(); + result.ShowModsList.Should().BeFalse(); + result.ShowPatchesList.Should().BeFalse(); + result.ShowAddModButton.Should().BeFalse(); + result.RequiresOriginalGameContentLoad.Should().BeTrue(); + } + + [Fact] + public void GetStateDoesNotLoadOriginalGameContentWhenModIsSelected() + { + // Arrange + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedMod().Returns(new GameModification { Name = "Shockwave" }); + LauncherContentViewStateService service = CreateService(catalogQueries, connected: true); + + // Act + LauncherContentViewState result = service.GetState(LauncherContentViewKind.Patches); + + // Assert + result.ShowPatchesList.Should().BeTrue(); + result.RequiresOriginalGameContentLoad.Should().BeFalse(); + } + + [Fact] + public void HiddenHidesAllContentControls() + { + // Act + LauncherContentViewState result = LauncherContentViewState.Hidden; + + // Assert + result.ShowModsList.Should().BeFalse(); + result.ShowPatchesList.Should().BeFalse(); + result.ShowAddonsList.Should().BeFalse(); + result.ShowManualAddMod.Should().BeFalse(); + result.ShowManualAddPatch.Should().BeFalse(); + result.ShowManualAddAddon.Should().BeFalse(); + result.ShowAddModButton.Should().BeFalse(); + result.RequiresOriginalGameContentLoad.Should().BeFalse(); + } + + private static LauncherContentViewStateService CreateService(bool connected) + { + return CreateService(Substitute.For(), connected); + } + + private static LauncherContentViewStateService CreateService( + ILauncherContentCatalogQueries catalogQueries, + bool connected) + { + LauncherRuntimeContext runtimeContext = new(CreateLauncherPaths(), "1.0") + { + Connected = connected + }; + + return new LauncherContentViewStateService(catalogQueries, runtimeContext); + } + + private static LauncherPaths CreateLauncherPaths() + { + return new LauncherPaths( + GameDirectory: @"C:\Game", + LauncherDirectory: @"C:\Launcher", + RuntimeDirectory: @"C:\Launcher\Runtime", + CacheDirectory: @"C:\Launcher\Cache", + ImagesDirectory: @"C:\Launcher\Images", + ModsDirectory: @"C:\Launcher\Mods", + LogsDirectory: @"C:\Launcher\Logs", + TempDirectory: @"C:\Launcher\Temp", + DeploymentDirectory: @"C:\Launcher\Deployment"); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherExecutableSelectionServiceTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherExecutableSelectionServiceTests.cs new file mode 100644 index 00000000..57626573 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherExecutableSelectionServiceTests.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Launcher.Models; +using GenLauncherGO.UI.Features.Launcher.Services; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Services; + +public sealed class LauncherExecutableSelectionServiceTests +{ + [Fact] + public void GetGameClientOptionsMapsDiscoveredClientsToLocalizedOptions() + { + // Arrange + IGameExecutableDiscoveryService discovery = Substitute.For(); + discovery.GetAvailableGameClients(SupportedGame.ZeroHour).Returns(new[] + { + new GameClientExecutable("generalszh.exe", GameClientExecutableKind.Community), + new GameClientExecutable("generalsonlinezh.exe", GameClientExecutableKind.GeneralsOnline) + }); + LauncherExecutableSelectionService service = CreateService(discovery); + + // Act + IReadOnlyList options = service.GetGameClientOptions(); + + // Assert + options.Select(option => option.DisplayName) + .Should() + .Equal("SuperHackers client", "GeneralsOnline client"); + options.Select(option => option.ExecutableName) + .Should() + .Equal("generalszh.exe", "generalsonlinezh.exe"); + options[1].IsGeneralsOnline.Should().BeTrue(); + } + + [Fact] + public void SelectGameClientOptionPrefersSavedExecutable() + { + // Arrange + GameClientOption first = new("First", "first.exe", GameClientExecutableKind.Community); + GameClientOption second = new("Second", "second.exe", GameClientExecutableKind.GeneralsOnline); + LauncherExecutableSelectionService service = CreateService(); + + // Act + GameClientOption? selected = service.SelectGameClientOption( + new[] { first, second }, + "second.exe"); + + // Assert + selected.Should().BeSameAs(second); + } + + [Fact] + public void SelectGameClientOptionFallsBackToFirstOption() + { + // Arrange + GameClientOption first = new("First", "first.exe", GameClientExecutableKind.Community); + GameClientOption second = new("Second", "second.exe", GameClientExecutableKind.GeneralsOnline); + LauncherExecutableSelectionService service = CreateService(); + + // Act + GameClientOption? selected = service.SelectGameClientOption( + new[] { first, second }, + "missing.exe"); + + // Assert + selected.Should().BeSameAs(first); + } + + [Fact] + public void GetWorldBuilderOptionsMapsDiscoveredWorldBuildersToLocalizedOptions() + { + // Arrange + IGameExecutableDiscoveryService discovery = Substitute.For(); + discovery.GetAvailableWorldBuilders(SupportedGame.ZeroHour).Returns(new[] + { + new WorldBuilderExecutable("WorldBuilder.exe", WorldBuilderExecutableKind.Vanilla), + new WorldBuilderExecutable("worldbuilderzh.exe", WorldBuilderExecutableKind.Community) + }); + LauncherExecutableSelectionService service = CreateService(discovery); + + // Act + IReadOnlyList options = service.GetWorldBuilderOptions(); + + // Assert + options.Select(option => option.DisplayName) + .Should() + .Equal("Vanilla World Builder", "SuperHackers World Builder"); + options.Select(option => option.ExecutableName) + .Should() + .Equal("WorldBuilder.exe", "worldbuilderzh.exe"); + options.Should().OnlyContain(option => option.IsAvailable); + } + + [Fact] + public void GetWorldBuilderOptionsReturnsUnavailablePlaceholderWhenNoneAreFound() + { + // Arrange + IGameExecutableDiscoveryService discovery = Substitute.For(); + discovery.GetAvailableWorldBuilders(SupportedGame.ZeroHour).Returns(Array.Empty()); + LauncherExecutableSelectionService service = CreateService(discovery); + + // Act + IReadOnlyList options = service.GetWorldBuilderOptions(); + + // Assert + options.Should().ContainSingle(); + options[0].DisplayName.Should().Be("No World Builders Found"); + options[0].ExecutableName.Should().BeEmpty(); + options[0].IsAvailable.Should().BeFalse(); + } + + [Fact] + public void SelectWorldBuilderOptionPrefersSavedExecutable() + { + // Arrange + ExecutableOption first = new("First", "first.exe", WorldBuilderExecutableKind.Vanilla); + ExecutableOption second = new("Second", "second.exe", WorldBuilderExecutableKind.Community); + LauncherExecutableSelectionService service = CreateService(); + + // Act + ExecutableOption? selected = service.SelectWorldBuilderOption( + new[] { first, second }, + "second.exe"); + + // Assert + selected.Should().BeSameAs(second); + } + + private static LauncherExecutableSelectionService CreateService() + { + return CreateService(Substitute.For()); + } + + private static LauncherExecutableSelectionService CreateService(IGameExecutableDiscoveryService discovery) + { + return new LauncherExecutableSelectionService( + discovery, + new TestLauncherModsContext(), + new TestStringLocalizer(new Dictionary + { + ["GeneralsOnlineClient"] = "GeneralsOnline client", + ["SuperHackersClient"] = "SuperHackers client", + ["VanillaWorldBuilder"] = "Vanilla World Builder", + ["SuperHackersWorldBuilder"] = "SuperHackers World Builder", + ["NoWorldBuildersFound"] = "No World Builders Found", + })); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherGameArgumentServiceTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherGameArgumentServiceTests.cs new file mode 100644 index 00000000..483f8f0a --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherGameArgumentServiceTests.cs @@ -0,0 +1,73 @@ +using GenLauncherGO.UI.Features.Launcher.Services; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Services; + +public sealed class LauncherGameArgumentServiceTests +{ + [Fact] + public void SetArgumentEnabledAddsArgumentWhenMissing() + { + // Arrange + string arguments = "-foo"; + + // Act + string result = LauncherGameArgumentService.SetArgumentEnabled( + arguments, + LauncherGameArgumentService.WindowedArgument, + enabled: true); + + // Assert + result.Should().Be("-foo -win"); + } + + [Fact] + public void SetArgumentEnabledDoesNotDuplicateExistingArgument() + { + // Arrange + string arguments = "-foo -WIN"; + + // Act + string result = LauncherGameArgumentService.SetArgumentEnabled( + arguments, + LauncherGameArgumentService.WindowedArgument, + enabled: true); + + // Assert + result.Should().Be("-foo -WIN"); + } + + [Fact] + public void SetArgumentEnabledRemovesStandaloneArgumentAndKeepsOtherArguments() + { + // Arrange + string arguments = "-foo \"bar baz\" -win -quickstart"; + + // Act + string result = LauncherGameArgumentService.SetArgumentEnabled( + arguments, + LauncherGameArgumentService.WindowedArgument, + enabled: false); + + // Assert + result.Should().Be("-foo \"bar baz\" -quickstart"); + } + + [Fact] + public void ContainsArgumentRequiresStandaloneArgument() + { + // Arrange + string arguments = "-windowed -quickstart"; + + // Act + bool containsWindowed = LauncherGameArgumentService.ContainsArgument( + arguments, + LauncherGameArgumentService.WindowedArgument); + bool containsQuickStart = LauncherGameArgumentService.ContainsArgument( + arguments, + LauncherGameArgumentService.QuickStartArgument); + + // Assert + containsWindowed.Should().BeFalse(); + containsQuickStart.Should().BeTrue(); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherLaunchCoordinatorTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherLaunchCoordinatorTests.cs new file mode 100644 index 00000000..69457327 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherLaunchCoordinatorTests.cs @@ -0,0 +1,761 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Settings.Contracts; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Models; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Launcher.Models; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Mods.Contracts; +using GenLauncherGO.UI.Shared.Localization; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Services; + +public sealed class LauncherLaunchCoordinatorTests +{ + [Fact] + public void LaunchAsyncWhenPackageActivityIsActiveStopsBeforeVerification() + { + StaTestRunner.Run(async () => + { + // Arrange + LauncherPackageActivityService packageActivityService = new(); + packageActivityService.TryBegin("Download", out LauncherPackageActivityService.LauncherPackageActivityLease? lease) + .Should().BeTrue(); + ILauncherDialogService dialogService = Substitute.For(); + ILaunchContentIntegrityResolutionService resolutionService = + Substitute.For(); + LauncherLaunchCoordinator coordinator = CreateCoordinator( + packageActivityService: packageActivityService, + resolutionService: resolutionService, + dialogService: dialogService); + + try + { + // Act + LauncherLaunchResult result = await coordinator.LaunchAsync(CreateRequest(), CancellationToken.None); + + // Assert + result.LaunchStarted.Should().BeFalse(); + result.FailureKind.Should().Be(LauncherLaunchFailureKind.VerificationAlreadyRunning); + coordinator.IsGameRunning.Should().BeFalse(); + await resolutionService.DidNotReceive().VerifyAsync( + Arg.Any(), + Arg.Any()); + dialogService.Received(1).ShowError( + Arg.Is(request => + request.MainMessage == "Launch aborted" && + request.DetailMessage == "Verification running")); + } + finally + { + lease?.Dispose(); + } + }); + } + + [Fact] + public void LaunchAsyncWhenIntegrityReviewIsCanceledStopsBeforePreparation() + { + StaTestRunner.Run(async () => + { + // Arrange + ILauncherDialogService dialogService = Substitute.For(); + dialogService.ShowIntegrityReview( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(false); + ILaunchPreparationService preparationService = Substitute.For(); + LauncherLaunchCoordinator coordinator = CreateCoordinator( + launchPreparationService: preparationService, + resolutionService: CreateResolutionServiceWithIssues(), + dialogService: dialogService); + bool disabled = false; + + // Act + LauncherLaunchResult result = await coordinator.LaunchAsync( + CreateRequest(disableUi: () => disabled = true), + CancellationToken.None); + + // Assert + result.LaunchStarted.Should().BeFalse(); + result.FailureKind.Should().Be(LauncherLaunchFailureKind.VerificationCanceled); + disabled.Should().BeFalse(); + await preparationService.DidNotReceive().PrepareAsync( + Arg.Any(), + Arg.Any()); + }); + } + + [Fact] + public void LaunchAsyncWhenPreparationFailsShowsInstallErrorAndCleansUp() + { + StaTestRunner.Run(async () => + { + // Arrange + ILaunchPreparationService preparationService = Substitute.For(); + preparationService.PrepareAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(LaunchPreparationResult.Failure(Array.Empty()))); + preparationService.CleanupAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(LaunchPreparationResult.Success())); + ILauncherDialogService dialogService = Substitute.For(); + LauncherLaunchCoordinator coordinator = CreateCoordinator( + launchPreparationService: preparationService, + dialogService: dialogService); + bool disabled = false; + bool enabled = false; + + // Act + LauncherLaunchResult result = await coordinator.LaunchAsync( + CreateRequest( + disableUi: () => disabled = true, + enableUi: () => enabled = true), + CancellationToken.None); + + // Assert + result.LaunchStarted.Should().BeFalse(); + result.FailureKind.Should().Be(LauncherLaunchFailureKind.PreparationFailed); + disabled.Should().BeTrue(); + enabled.Should().BeTrue(); + await preparationService.Received(1).CleanupAsync( + Arg.Any(), + Arg.Any()); + dialogService.Received(1).ShowError( + Arg.Is(request => + request.MainMessage == "Files corrupted" && + request.DetailMessage == "Reinstall")); + }); + } + + [Fact] + public void LaunchAsyncWhenGameLaunchSucceedsBuildsGameLaunchRequestAndCleansUp() + { + StaTestRunner.Run(async () => + { + // Arrange + ILaunchPreparationService preparationService = CreateSuccessfulPreparationService(); + IGameProcessLauncher processLauncher = Substitute.For(); + GameLaunchRequest? capturedRequest = null; + processLauncher.StartAsync( + Arg.Do(request => capturedRequest = request), + Arg.Any()) + .Returns(Task.FromResult(CreateProcessOperation(GameLaunchResult.Success( + "generals.exe", + "-win", + TimeSpan.FromSeconds(1))))); + LauncherLaunchCoordinator coordinator = CreateCoordinator( + launcherPreferencesService: CreatePreferencesService(new LauncherPreferences + { + GameArguments = "-win" + }), + launchPreparationService: preparationService, + gameProcessLauncher: processLauncher); + + // Act + LauncherLaunchResult result = await coordinator.LaunchAsync( + CreateRequest(useGeneralsOnline: false), + CancellationToken.None); + + // Assert + result.LaunchStarted.Should().BeTrue(); + result.ProcessSucceeded.Should().BeTrue(); + capturedRequest.Should().NotBeNull(); + capturedRequest!.TargetKind.Should().Be(GameLaunchTargetKind.GameClient); + capturedRequest.ManagedGame.Should().Be(SupportedGame.ZeroHour); + capturedRequest.UseGeneralsOnline.Should().BeFalse(); + capturedRequest.Arguments.Should().Be("-win"); + await preparationService.Received(1).CleanupAsync( + Arg.Any(), + Arg.Any()); + }); + } + + [Fact] + public void LaunchAsyncForModdedGameRequestsBaseGameScriptDisable() + { + StaTestRunner.Run(async () => + { + // Arrange + ILaunchPreparationService preparationService = Substitute.For(); + LaunchPreparationRequest? capturedPreparationRequest = null; + preparationService.PrepareAsync( + Arg.Do(request => capturedPreparationRequest = request), + Arg.Any()) + .Returns(Task.FromResult(LaunchPreparationResult.Success())); + preparationService.CleanupAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(LaunchPreparationResult.Success())); + LauncherLaunchCoordinator coordinator = CreateCoordinator(launchPreparationService: preparationService); + + // Act + LauncherLaunchResult result = await coordinator.LaunchAsync( + CreateRequest(activeVersions: new[] + { + new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "Rise", + Version = "1.0" + } + }), + CancellationToken.None); + + // Assert + result.LaunchStarted.Should().BeTrue(); + capturedPreparationRequest.Should().NotBeNull(); + capturedPreparationRequest!.DisableBaseGameScriptFiles.Should().BeTrue(); + }); + } + + [Fact] + public void LaunchAsyncForUnmoddedGameDoesNotRequestBaseGameScriptDisable() + { + StaTestRunner.Run(async () => + { + // Arrange + ILaunchPreparationService preparationService = Substitute.For(); + LaunchPreparationRequest? capturedPreparationRequest = null; + preparationService.PrepareAsync( + Arg.Do(request => capturedPreparationRequest = request), + Arg.Any()) + .Returns(Task.FromResult(LaunchPreparationResult.Success())); + preparationService.CleanupAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(LaunchPreparationResult.Success())); + LauncherLaunchCoordinator coordinator = CreateCoordinator(launchPreparationService: preparationService); + + // Act + LauncherLaunchResult result = await coordinator.LaunchAsync(CreateRequest(), CancellationToken.None); + + // Assert + result.LaunchStarted.Should().BeTrue(); + capturedPreparationRequest.Should().NotBeNull(); + capturedPreparationRequest!.DisableBaseGameScriptFiles.Should().BeFalse(); + }); + } + + [Fact] + public void LaunchAsyncForModdedWorldBuilderRequestsBaseGameScriptDisable() + { + StaTestRunner.Run(async () => + { + // Arrange + ILaunchPreparationService preparationService = Substitute.For(); + LaunchPreparationRequest? capturedPreparationRequest = null; + preparationService.PrepareAsync( + Arg.Do(request => capturedPreparationRequest = request), + Arg.Any()) + .Returns(Task.FromResult(LaunchPreparationResult.Success())); + preparationService.CleanupAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(LaunchPreparationResult.Success())); + LauncherLaunchCoordinator coordinator = CreateCoordinator(launchPreparationService: preparationService); + + // Act + LauncherLaunchResult result = await coordinator.LaunchAsync( + CreateRequest( + targetKind: LauncherLaunchTargetKind.WorldBuilder, + activeVersions: new[] + { + new ModificationVersion + { + ModificationType = ModificationType.Mod, + Name = "Rise", + Version = "1.0" + } + }), + CancellationToken.None); + + // Assert + result.LaunchStarted.Should().BeTrue(); + capturedPreparationRequest.Should().NotBeNull(); + capturedPreparationRequest!.DisableBaseGameScriptFiles.Should().BeTrue(); + }); + } + + [Fact] + public void LaunchAsyncForUnmoddedWorldBuilderDoesNotRequestBaseGameScriptDisable() + { + StaTestRunner.Run(async () => + { + // Arrange + ILaunchPreparationService preparationService = Substitute.For(); + LaunchPreparationRequest? capturedPreparationRequest = null; + preparationService.PrepareAsync( + Arg.Do(request => capturedPreparationRequest = request), + Arg.Any()) + .Returns(Task.FromResult(LaunchPreparationResult.Success())); + preparationService.CleanupAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(LaunchPreparationResult.Success())); + LauncherLaunchCoordinator coordinator = CreateCoordinator(launchPreparationService: preparationService); + + // Act + LauncherLaunchResult result = await coordinator.LaunchAsync( + CreateRequest(targetKind: LauncherLaunchTargetKind.WorldBuilder), + CancellationToken.None); + + // Assert + result.LaunchStarted.Should().BeTrue(); + capturedPreparationRequest.Should().NotBeNull(); + capturedPreparationRequest!.DisableBaseGameScriptFiles.Should().BeFalse(); + }); + } + + [Fact] + public void LaunchAsyncWhenWorldBuilderLaunchSucceedsUsesSelectedExecutableAndArguments() + { + StaTestRunner.Run(async () => + { + // Arrange + IGameProcessLauncher processLauncher = Substitute.For(); + GameLaunchRequest? capturedRequest = null; + processLauncher.StartAsync( + Arg.Do(request => capturedRequest = request), + Arg.Any()) + .Returns(Task.FromResult(CreateProcessOperation(GameLaunchResult.Success( + "worldbuilder.exe", + "-wb", + TimeSpan.FromSeconds(1))))); + LauncherLaunchCoordinator coordinator = CreateCoordinator( + launcherPreferencesService: CreatePreferencesService(new LauncherPreferences + { + WorldBuilderArguments = "-wb" + }), + gameProcessLauncher: processLauncher); + + // Act + LauncherLaunchResult result = await coordinator.LaunchAsync( + CreateRequest( + targetKind: LauncherLaunchTargetKind.WorldBuilder, + executableName: "worldbuilder.exe"), + CancellationToken.None); + + // Assert + result.LaunchStarted.Should().BeTrue(); + capturedRequest.Should().NotBeNull(); + capturedRequest!.TargetKind.Should().Be(GameLaunchTargetKind.WorldBuilder); + capturedRequest.ExecutableName.Should().Be("worldbuilder.exe"); + capturedRequest.Arguments.Should().Be("-wb"); + }); + } + + [Fact] + public void LaunchAsyncShowsOverlayUntilTrackedProcessCompletesAndForceClosesActiveProcess() + { + StaTestRunner.Run(async () => + { + // Arrange + IGameProcessLauncher processLauncher = Substitute.For(); + IGameProcessLaunchOperation operation = Substitute.For(); + var processCompletion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var overlayShown = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + bool overlayHidden = false; + operation.ExecutableName.Returns("generals.exe"); + operation.CurrentExecutableName.Returns("generals.exe"); + operation.Completion.Returns(processCompletion.Task); + processLauncher.StartAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(operation)); + LauncherLaunchCoordinator coordinator = CreateCoordinator(gameProcessLauncher: processLauncher); + + // Act + Task launchTask = coordinator.LaunchAsync( + CreateRequest( + showRunningProcessOverlay: processName => overlayShown.SetResult(processName), + hideRunningProcessOverlay: () => overlayHidden = true), + CancellationToken.None); + + // Assert + string shownProcessName = await overlayShown.Task.WaitAsync(TimeSpan.FromSeconds(5)); + shownProcessName.Should().Be("generals.exe"); + coordinator.HasActiveProcess.Should().BeTrue(); + coordinator.ActiveProcessName.Should().Be("generals.exe"); + + coordinator.ForceCloseActiveProcess().Should().BeTrue(); + operation.Received(1).ForceClose(); + overlayHidden.Should().BeFalse(); + + processCompletion.SetResult(GameLaunchResult.Success( + "generals.exe", + string.Empty, + TimeSpan.FromSeconds(20))); + LauncherLaunchResult result = await launchTask; + + result.ProcessSucceeded.Should().BeTrue(); + coordinator.HasActiveProcess.Should().BeFalse(); + overlayHidden.Should().BeTrue(); + }); + } + + [Fact] + public void LaunchAsyncWhenHideLauncherAfterGameStartIsEnabledHidesOwnerUntilTrackedProcessCompletes() + { + StaTestRunner.Run(async () => + { + // Arrange + IGameProcessLauncher processLauncher = Substitute.For(); + IGameProcessLaunchOperation operation = Substitute.For(); + var processStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var processCompletion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var overlayProcessNames = new List(); + int hideLauncherWindowCount = 0; + int showLauncherWindowCount = 0; + operation.ExecutableName.Returns("generals.exe"); + operation.CurrentExecutableName.Returns("generals.exe"); + operation.Completion.Returns(processCompletion.Task); + processLauncher.StartAsync(Arg.Any(), Arg.Any()) + .Returns(_ => + { + processStarted.SetResult(); + return Task.FromResult(operation); + }); + LauncherLaunchCoordinator coordinator = CreateCoordinator( + launcherPreferencesService: CreatePreferencesService(new LauncherPreferences + { + HideLauncherAfterGameStart = true + }), + gameProcessLauncher: processLauncher); + + // Act + Task launchTask = coordinator.LaunchAsync( + CreateRequest( + showRunningProcessOverlay: processName => overlayProcessNames.Add(processName), + hideLauncherWindow: () => hideLauncherWindowCount++, + showLauncherWindow: () => showLauncherWindowCount++), + CancellationToken.None); + + // Assert + await processStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + hideLauncherWindowCount.Should().Be(1); + showLauncherWindowCount.Should().Be(0); + overlayProcessNames.Should().BeEmpty(); + + processCompletion.SetResult(GameLaunchResult.Success( + "generals.exe", + string.Empty, + TimeSpan.FromSeconds(20))); + LauncherLaunchResult result = await launchTask.WaitAsync(TimeSpan.FromSeconds(5)); + + result.ProcessSucceeded.Should().BeTrue(); + hideLauncherWindowCount.Should().Be(1); + showLauncherWindowCount.Should().Be(1); + overlayProcessNames.Should().BeEmpty(); + }); + } + + [Fact] + public void LaunchAsyncUpdatesOverlayWhenTrackedCurrentProcessChanges() + { + StaTestRunner.Run(async () => + { + // Arrange + IGameProcessLauncher processLauncher = Substitute.For(); + var operation = new ControllableGameProcessLaunchOperation("generalsonlinezh.exe"); + var initialOverlayShown = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var gameOverlayShown = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var overlayProcessNames = new List(); + processLauncher.StartAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(operation)); + LauncherLaunchCoordinator coordinator = CreateCoordinator(gameProcessLauncher: processLauncher); + + // Act + Task launchTask = coordinator.LaunchAsync( + CreateRequest( + showRunningProcessOverlay: processName => + { + overlayProcessNames.Add(processName); + initialOverlayShown.TrySetResult(processName); + if (processName == "generalszh.exe") + { + gameOverlayShown.TrySetResult(); + } + }), + CancellationToken.None); + + // Assert + string initialProcessName = await initialOverlayShown.Task.WaitAsync(TimeSpan.FromSeconds(5)); + initialProcessName.Should().Be("generalsonlinezh.exe"); + + operation.SetCurrentExecutableName("generalszh.exe"); + await gameOverlayShown.Task.WaitAsync(TimeSpan.FromSeconds(5)); + coordinator.ActiveProcessName.Should().Be("generalszh.exe"); + + operation.Complete(GameLaunchResult.Success( + "generalsonlinezh.exe", + string.Empty, + TimeSpan.FromSeconds(20))); + LauncherLaunchResult result = await launchTask; + + result.ProcessSucceeded.Should().BeTrue(); + overlayProcessNames.Should().Equal("generalsonlinezh.exe", "generalszh.exe"); + }); + } + + private static LauncherLaunchCoordinator CreateCoordinator( + ILauncherPreferencesService? launcherPreferencesService = null, + ILaunchPreparationService? launchPreparationService = null, + IGameProcessLauncher? gameProcessLauncher = null, + ILaunchContentIntegrityResolutionService? resolutionService = null, + LauncherPackageActivityService? packageActivityService = null, + ILauncherDialogService? dialogService = null) + { + ILauncherDialogService resolvedDialogService = dialogService ?? Substitute.For(); + LauncherPackageActivityService resolvedPackageActivityService = packageActivityService ?? new(); + + return new LauncherLaunchCoordinator( + launcherPreferencesService ?? CreatePreferencesService(new LauncherPreferences()), + launchPreparationService ?? CreateSuccessfulPreparationService(), + gameProcessLauncher ?? CreateSuccessfulProcessLauncher(), + CreateIntegrityCoordinator( + resolutionService ?? CreateNoIssueResolutionService(), + resolvedPackageActivityService, + resolvedDialogService), + resolvedPackageActivityService, + CreatePaths(), + new LauncherContentLayout("Addons", "Patches"), + new TestLauncherModsContext(SupportedGame.ZeroHour), + CreateStringLocalizer(), + resolvedDialogService, + NullLogger.Instance); + } + + private static LauncherLaunchRequest CreateRequest( + LauncherLaunchTargetKind targetKind = LauncherLaunchTargetKind.GameClient, + string executableName = "generals.exe", + bool useGeneralsOnline = false, + IReadOnlyList? activeVersions = null, + Action? disableUi = null, + Action? enableUi = null, + Window? owner = null, + Action? showRunningProcessOverlay = null, + Action? hideRunningProcessOverlay = null, + Action? hideLauncherWindow = null, + Action? showLauncherWindow = null) + { + return new LauncherLaunchRequest( + targetKind, + executableName, + useGeneralsOnline, + activeVersions ?? Array.Empty(), + Array.Empty(), + owner ?? new Window(), + disableUi ?? (() => { }), + enableUi ?? (() => { }), + showRunningProcessOverlay, + hideRunningProcessOverlay, + hideLauncherWindow, + showLauncherWindow); + } + + private static ILauncherPreferencesService CreatePreferencesService(LauncherPreferences preferences) + { + ILauncherPreferencesService preferencesService = Substitute.For(); + preferencesService.Current.Returns(preferences); + return preferencesService; + } + + private static ILaunchPreparationService CreateSuccessfulPreparationService() + { + ILaunchPreparationService preparationService = Substitute.For(); + preparationService.PrepareAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(LaunchPreparationResult.Success())); + preparationService.CleanupAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(LaunchPreparationResult.Success())); + return preparationService; + } + + private static IGameProcessLauncher CreateSuccessfulProcessLauncher() + { + IGameProcessLauncher processLauncher = Substitute.For(); + processLauncher.StartAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(CreateProcessOperation(GameLaunchResult.Success( + "generals.exe", + string.Empty, + TimeSpan.FromSeconds(1))))); + return processLauncher; + } + + private static IGameProcessLaunchOperation CreateProcessOperation(GameLaunchResult result) + { + return new TestGameProcessLaunchOperation(result); + } + + private static LaunchContentIntegrityCoordinator CreateIntegrityCoordinator( + ILaunchContentIntegrityResolutionService resolutionService, + LauncherPackageActivityService packageActivityService, + ILauncherDialogService dialogService) + { + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetAllModsVersionsList().Returns(Array.Empty()); + + return new LaunchContentIntegrityCoordinator( + resolutionService, + catalogQueries, + CreatePaths(), + new LauncherContentLayout("Addons", "Patches"), + packageActivityService, + CreateStringLocalizer(), + dialogService, + NullLogger.Instance); + } + + private static ILaunchContentIntegrityResolutionService CreateNoIssueResolutionService() + { + ILaunchContentIntegrityResolutionService resolutionService = + Substitute.For(); + resolutionService.VerifyAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(new LaunchContentIntegrityVerificationResult( + new ContentIntegrityReport(Array.Empty()), + Array.Empty()))); + return resolutionService; + } + + private static ILaunchContentIntegrityResolutionService CreateResolutionServiceWithIssues() + { + ILaunchContentIntegrityResolutionService resolutionService = + Substitute.For(); + resolutionService.VerifyAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(new LaunchContentIntegrityVerificationResult( + new ContentIntegrityReport(new[] + { + new ContentIntegrityIssue( + "target", + "Shockwave", + ContentSourceKind.Manual, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.Absorb, + "Data/file.big") + }), + Array.Empty()))); + resolutionService.InitializeUntrackedManagedCachesAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(false)); + return resolutionService; + } + + private static ILauncherStringLocalizer CreateStringLocalizer() + { + return new TestStringLocalizer(new Dictionary + { + ["AbsorbAndLaunch"] = "Absorb and launch", + ["CancelLaunch"] = "Cancel launch", + ["FilesCorrupted"] = "Files corrupted", + ["GameRunning"] = "Game running", + ["IntegrityAbsorbGroup"] = "Absorb", + ["IntegrityBlockGroup"] = "Blocked", + ["IntegrityBlockedDescription"] = "Blocked", + ["IntegrityCacheSuffix"] = " cache", + ["IntegrityDeleteGroup"] = "Delete", + ["IntegrityDialogTitle"] = "Integrity", + ["IntegrityManualDescription"] = "Absorb {0}", + ["IntegrityRepairGroup"] = "Repair", + ["IntegrityRedownloadGroup"] = "Redownload", + ["IntegrityTrustGroup"] = "Trust", + ["LaunchAborted"] = "Launch aborted", + ["LaunchVerificationRunning"] = "Verification running", + ["Reinstall"] = "Reinstall", + ["WorldBuilderRunning"] = "World Builder running", + }); + } + + private static LauncherPaths CreatePaths() + { + return new LauncherPaths( + @"C:\Games\ZeroHour", + @"C:\Games\ZeroHour\GenLauncherGO", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache\Images", + @"C:\Games\ZeroHour\GenLauncherGO\Mods", + @"C:\Games\ZeroHour\GenLauncherGO\Logs", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Temp", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Deployment"); + } + + private sealed class TestGameProcessLaunchOperation : IGameProcessLaunchOperation + { + public TestGameProcessLaunchOperation(GameLaunchResult result) + { + ArgumentNullException.ThrowIfNull(result); + + ExecutableName = result.ExecutableName; + Completion = Task.FromResult(result); + } + + public string ExecutableName { get; } + + public string CurrentExecutableName => ExecutableName; + + public event EventHandler? CurrentExecutableNameChanged + { + add { } + remove { } + } + + public Task Completion { get; } + + public void ForceClose() + { + } + } + + private sealed class ControllableGameProcessLaunchOperation : IGameProcessLaunchOperation + { + private readonly TaskCompletionSource _completion = new( + TaskCreationOptions.RunContinuationsAsynchronously); + + public ControllableGameProcessLaunchOperation(string executableName) + { + ExecutableName = executableName; + CurrentExecutableName = executableName; + } + + public string ExecutableName { get; } + + public string CurrentExecutableName { get; private set; } + + public event EventHandler? CurrentExecutableNameChanged; + + public Task Completion => _completion.Task; + + public void SetCurrentExecutableName(string executableName) + { + CurrentExecutableName = executableName; + CurrentExecutableNameChanged?.Invoke(this, EventArgs.Empty); + } + + public void Complete(GameLaunchResult result) + { + _completion.SetResult(result); + } + + public void ForceClose() + { + } + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherLaunchReadinessCoordinatorTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherLaunchReadinessCoordinatorTests.cs new file mode 100644 index 00000000..092a469d --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherLaunchReadinessCoordinatorTests.cs @@ -0,0 +1,633 @@ +using System; +using System.Collections.Generic; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Windows; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Models; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Mods; +using GenLauncherGO.UI.Features.Mods.Contracts; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Services; + +public sealed class LauncherLaunchReadinessCoordinatorTests +{ + [Fact] + public void EnsureExecutableAvailableReturnsTrueWhenExecutableExists() + { + RunOnStaThread(() => + { + // Arrange + IGameExecutableDiscoveryService discovery = Substitute.For(); + discovery.IsExecutableAvailable("generals.exe").Returns(true); + LauncherLaunchReadinessCoordinator coordinator = CreateCoordinator(discovery); + Window owner = new(); + + // Act + bool available = coordinator.EnsureExecutableAvailable( + "generals.exe", + "Missing executable", + owner); + + // Assert + available.Should().BeTrue(); + }); + } + + [Fact] + public void EnsureExecutableAvailableShowsErrorWhenExecutableIsMissing() + { + RunOnStaThread(() => + { + // Arrange + IGameExecutableDiscoveryService discovery = Substitute.For(); + discovery.IsExecutableAvailable("missing.exe").Returns(false); + ILauncherDialogService dialogService = Substitute.For(); + LauncherLaunchReadinessCoordinator coordinator = CreateCoordinator( + gameExecutableDiscoveryService: discovery, + dialogService: dialogService); + Window owner = new(); + + // Act + bool available = coordinator.EnsureExecutableAvailable( + "missing.exe", + "Missing executable", + owner); + + // Assert + available.Should().BeFalse(); + dialogService.Received(1).ShowError( + Arg.Is(request => + request.MainMessage == "Launch aborted" && + request.DetailMessage == "Missing executable"), + owner); + }); + } + + [Fact] + public void EnsureSelectedContentCanLaunchRejectsAdvertisingSelection() + { + RunOnStaThread(() => + { + // Arrange + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedMod().Returns(new GameModification + { + ModificationType = ModificationType.Advertising + }); + LauncherLaunchReadinessCoordinator coordinator = CreateCoordinator(catalogQueries: catalogQueries); + Window owner = new(); + + // Act + bool canLaunch = coordinator.EnsureSelectedContentCanLaunch( + Array.Empty(), + Array.Empty(), + Array.Empty(), + owner); + + // Assert + canLaunch.Should().BeFalse(); + }); + } + + [Fact] + public void EnsureSelectedContentCanLaunchShowsErrorForActiveSelectedModDownload() + { + RunOnStaThread(() => + { + // Arrange + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedModVersion().Returns(new ModificationVersion + { + Name = "ShockWave", + Version = "1.0", + Installed = true + }); + ILauncherDialogService dialogService = Substitute.For(); + LauncherLaunchReadinessCoordinator coordinator = CreateCoordinator( + catalogQueries: catalogQueries, + dialogService: dialogService); + ModificationViewModel selectedMod = CreateViewModel("ShockWave", ModificationType.Mod); + selectedMod.BeginIntegrityProgress("Repairing"); + Window owner = new(); + + // Act + bool canLaunch = coordinator.EnsureSelectedContentCanLaunch( + new[] { selectedMod }, + Array.Empty(), + Array.Empty(), + owner); + + // Assert + canLaunch.Should().BeFalse(); + dialogService.Received(1).ShowError( + Arg.Is(request => + request.MainMessage == "Launch aborted" && + request.DetailMessage == "ShockWave install running"), + owner); + }); + } + + [Fact] + public void EnsureSelectedContentCanLaunchShowsErrorForActiveSelectedPatchDownload() + { + RunOnStaThread(() => + { + // Arrange + ILauncherDialogService dialogService = Substitute.For(); + LauncherLaunchReadinessCoordinator coordinator = CreateCoordinator(dialogService: dialogService); + ModificationViewModel selectedPatch = CreateViewModel("Balance", ModificationType.Patch); + selectedPatch.BeginIntegrityProgress("Repairing"); + Window owner = new(); + + // Act + bool canLaunch = coordinator.EnsureSelectedContentCanLaunch( + Array.Empty(), + new[] { selectedPatch }, + Array.Empty(), + owner); + + // Assert + canLaunch.Should().BeFalse(); + dialogService.Received(1).ShowError( + Arg.Is(request => + request.MainMessage == "Launch aborted" && + request.DetailMessage == "Balance install running"), + owner); + }); + } + + [Fact] + public void EnsureSelectedContentCanLaunchShowsErrorForActiveSelectedAddonDownload() + { + RunOnStaThread(() => + { + // Arrange + ILauncherDialogService dialogService = Substitute.For(); + LauncherLaunchReadinessCoordinator coordinator = CreateCoordinator(dialogService: dialogService); + ModificationViewModel selectedAddon = CreateViewModel("Music Pack", ModificationType.Addon); + selectedAddon.BeginIntegrityProgress("Repairing"); + Window owner = new(); + + // Act + bool canLaunch = coordinator.EnsureSelectedContentCanLaunch( + Array.Empty(), + Array.Empty(), + new[] { selectedAddon }, + owner); + + // Assert + canLaunch.Should().BeFalse(); + dialogService.Received(1).ShowError( + Arg.Is(request => + request.MainMessage == "Launch aborted" && + request.DetailMessage == "Music Pack is not installed"), + owner); + }); + } + + [Fact] + public void EnsureSelectedContentCanLaunchShowsErrorForUninstalledSelectedMod() + { + RunOnStaThread(() => + { + // Arrange + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedModVersion().Returns(new ModificationVersion + { + Name = "ShockWave", + Version = "1.0", + Installed = false + }); + ILauncherDialogService dialogService = Substitute.For(); + LauncherLaunchReadinessCoordinator coordinator = CreateCoordinator( + catalogQueries: catalogQueries, + dialogService: dialogService); + Window owner = new(); + + // Act + bool canLaunch = coordinator.EnsureSelectedContentCanLaunch( + Array.Empty(), + Array.Empty(), + Array.Empty(), + owner); + + // Assert + canLaunch.Should().BeFalse(); + dialogService.Received(1).ShowError( + Arg.Is(request => + request.MainMessage == "Launch aborted" && + request.DetailMessage == "ShockWave is not installed"), + owner); + }); + } + + [Fact] + public void EnsureSelectedContentCanLaunchShowsErrorForUninstalledSelectedPatch() + { + RunOnStaThread(() => + { + // Arrange + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedModVersion().Returns(new ModificationVersion + { + Name = "ShockWave", + Version = "1.0", + Installed = true + }); + catalogQueries.GetSelectedPatchVersion().Returns(new ModificationVersion + { + Name = "Balance", + Version = "2.0", + Installed = false + }); + ILauncherDialogService dialogService = Substitute.For(); + LauncherLaunchReadinessCoordinator coordinator = CreateCoordinator( + catalogQueries: catalogQueries, + dialogService: dialogService); + Window owner = new(); + + // Act + bool canLaunch = coordinator.EnsureSelectedContentCanLaunch( + Array.Empty(), + Array.Empty(), + Array.Empty(), + owner); + + // Assert + canLaunch.Should().BeFalse(); + dialogService.Received(1).ShowError( + Arg.Is(request => + request.MainMessage == "Launch aborted" && + request.DetailMessage == "Balance is not installed"), + owner); + }); + } + + [Fact] + public void EnsureSelectedContentCanLaunchShowsErrorForUninstalledSelectedAddon() + { + RunOnStaThread(() => + { + // Arrange + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedAddonsVersions().Returns(new[] + { + new ModificationVersion + { + Name = "Music Pack", + Version = "1.0", + Installed = false + } + }); + ILauncherDialogService dialogService = Substitute.For(); + LauncherLaunchReadinessCoordinator coordinator = CreateCoordinator( + catalogQueries: catalogQueries, + dialogService: dialogService); + Window owner = new(); + + // Act + bool canLaunch = coordinator.EnsureSelectedContentCanLaunch( + Array.Empty(), + Array.Empty(), + Array.Empty(), + owner); + + // Assert + canLaunch.Should().BeFalse(); + dialogService.Received(1).ShowError( + Arg.Is(request => + request.MainMessage == "Launch aborted" && + request.DetailMessage == "Music Pack is not installed"), + owner); + }); + } + + [Fact] + public void EnsureSelectedContentCanLaunchReturnsTrueWhenSelectionIsReadyAndInstalled() + { + RunOnStaThread(() => + { + // Arrange + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedModVersion().Returns(CreateVersion("ShockWave", "1.0", installed: true)); + catalogQueries.GetSelectedPatchVersion().Returns(CreateVersion("Balance", "2.0", installed: true)); + catalogQueries.GetSelectedAddonsVersions().Returns(new[] + { + CreateVersion("Music Pack", "1.0", installed: true) + }); + ILauncherDialogService dialogService = Substitute.For(); + LauncherLaunchReadinessCoordinator coordinator = CreateCoordinator( + catalogQueries: catalogQueries, + dialogService: dialogService); + Window owner = new(); + + // Act + bool canLaunch = coordinator.EnsureSelectedContentCanLaunch( + Array.Empty(), + Array.Empty(), + Array.Empty(), + owner); + + // Assert + canLaunch.Should().BeTrue(); + dialogService.DidNotReceive().ShowError( + Arg.Any(), + Arg.Any()); + }); + } + + [Fact] + public void ConfirmSelectedContentWarningsUsesUpdateConfirmationWhenLatestVersionIsNotInstalled() + { + RunOnStaThread(() => + { + // Arrange + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedMod().Returns(CreateModification( + "ShockWave", + ModificationType.Mod, + CreateVersion("ShockWave", "1.0", installed: true), + CreateVersion("ShockWave", "2.0", installed: false))); + catalogQueries.GetSelectedModVersions().Returns(new[] + { + CreateVersion("ShockWave", "1.0", installed: true), + CreateVersion("ShockWave", "2.0", installed: false) + }); + ILauncherDialogService dialogService = Substitute.For(); + dialogService.ShowWarningConfirmation( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(false); + LauncherLaunchReadinessCoordinator coordinator = CreateCoordinator( + catalogQueries: catalogQueries, + dialogService: dialogService); + Window owner = new(); + + // Act + bool confirmed = coordinator.ConfirmSelectedContentWarnings(owner); + + // Assert + confirmed.Should().BeFalse(); + dialogService.Received(1).ShowWarningConfirmation( + Arg.Is(request => + request.MainMessage == "Updates available" && + request.DetailMessage == "ShockWave update is not installed"), + null, + owner); + }); + } + + [Fact] + public void ConfirmSelectedContentWarningsUsesUpdateConfirmationForUninstalledPatchUpdate() + { + RunOnStaThread(() => + { + // Arrange + GameModification patch = CreateModification( + "Balance", + ModificationType.Patch, + CreateVersion("Balance", "1.0", installed: true), + CreateVersion("Balance", "2.0", installed: false)); + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedPatch().Returns(patch); + ILauncherDialogService dialogService = Substitute.For(); + dialogService.ShowWarningConfirmation( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(false); + LauncherLaunchReadinessCoordinator coordinator = CreateCoordinator( + catalogQueries: catalogQueries, + dialogService: dialogService); + Window owner = new(); + + // Act + bool confirmed = coordinator.ConfirmSelectedContentWarnings(owner); + + // Assert + confirmed.Should().BeFalse(); + dialogService.Received(1).ShowWarningConfirmation( + Arg.Is(request => + request.MainMessage == "Updates available" && + request.DetailMessage == "Balance update is not installed"), + null, + owner); + }); + } + + [Fact] + public void ConfirmSelectedContentWarningsUsesUpdateConfirmationForUninstalledAddonUpdate() + { + RunOnStaThread(() => + { + // Arrange + GameModification addon = CreateModification( + "Music Pack", + ModificationType.Addon, + CreateVersion("Music Pack", "1.0", installed: true), + CreateVersion("Music Pack", "2.0", installed: false)); + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedAddonsForSelectedMod().Returns(new[] { addon }); + ILauncherDialogService dialogService = Substitute.For(); + dialogService.ShowWarningConfirmation( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(false); + LauncherLaunchReadinessCoordinator coordinator = CreateCoordinator( + catalogQueries: catalogQueries, + dialogService: dialogService); + Window owner = new(); + + // Act + bool confirmed = coordinator.ConfirmSelectedContentWarnings(owner); + + // Assert + confirmed.Should().BeFalse(); + dialogService.Received(1).ShowWarningConfirmation( + Arg.Is(request => + request.MainMessage == "Updates available" && + request.DetailMessage == "Music Pack update is not installed"), + null, + owner); + }); + } + + [Fact] + public void ConfirmSelectedContentWarningsUsesCompatibilityConfirmationForDeprecatedPatch() + { + RunOnStaThread(() => + { + // Arrange + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedPatch().Returns(new GameModification + { + Name = "Balance", + Deprecated = true + }); + ILauncherDialogService dialogService = Substitute.For(); + dialogService.ShowWarningConfirmation( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(true); + LauncherLaunchReadinessCoordinator coordinator = CreateCoordinator( + catalogQueries: catalogQueries, + dialogService: dialogService); + Window owner = new(); + + // Act + bool confirmed = coordinator.ConfirmSelectedContentWarnings(owner); + + // Assert + confirmed.Should().BeTrue(); + dialogService.Received(1).ShowWarningConfirmation( + Arg.Is(request => + request.MainMessage == "Compatibility" && + request.DetailMessage == "Balance is deprecated"), + null, + owner); + }); + } + + [Fact] + public void ConfirmSelectedContentWarningsUsesCompatibilityConfirmationForDeprecatedAddon() + { + RunOnStaThread(() => + { + // Arrange + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedAddonsForSelectedMod().Returns(new[] + { + new GameModification + { + Name = "Music Pack", + Deprecated = true + } + }); + ILauncherDialogService dialogService = Substitute.For(); + dialogService.ShowWarningConfirmation( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(true); + LauncherLaunchReadinessCoordinator coordinator = CreateCoordinator( + catalogQueries: catalogQueries, + dialogService: dialogService); + Window owner = new(); + + // Act + bool confirmed = coordinator.ConfirmSelectedContentWarnings(owner); + + // Assert + confirmed.Should().BeTrue(); + dialogService.Received(1).ShowWarningConfirmation( + Arg.Is(request => + request.MainMessage == "Compatibility" && + request.DetailMessage == "Music Pack is deprecated"), + null, + owner); + }); + } + + private static LauncherLaunchReadinessCoordinator CreateCoordinator( + IGameExecutableDiscoveryService? gameExecutableDiscoveryService = null, + ILauncherContentCatalogQueries? catalogQueries = null, + ILauncherDialogService? dialogService = null) + { + return new LauncherLaunchReadinessCoordinator( + gameExecutableDiscoveryService ?? Substitute.For(), + catalogQueries ?? Substitute.For(), + dialogService ?? Substitute.For(), + new TestStringLocalizer(new Dictionary + { + ["LaunchAborted"] = "Launch aborted", + ["InstallInProgress"] = "{0} install running", + ["NotInstalled"] = "{0} is not installed", + ["ModIsUpToDate"] = "Up to date", + ["UninstalledUpdate"] = "{0} update is not installed", + ["ModificationsWithUpdate"] = "Updates available", + ["Deprecated"] = "{0} is deprecated", + ["Compatibility"] = "Compatibility", + })); + } + + private static ModificationViewModel CreateViewModel( + string name, + ModificationType modificationType, + string dependenceName = "") + { + return new ModificationViewModel( + CreateModification(name, modificationType, CreateVersion(name, "1.0", installed: true, dependenceName)), + new ModificationImageSourceFactory(NullLogger.Instance), + new TestLauncherModsContext(), + Substitute.For(), + new TestStringLocalizer(), + NullLogger.Instance); + } + + private static GameModification CreateModification( + string name, + ModificationType modificationType, + params ModificationVersion[] versions) + { + GameModification modification = new() + { + Name = name, + ModificationType = modificationType, + ModificationVersions = new List(versions) + }; + + return modification; + } + + private static ModificationVersion CreateVersion( + string name, + string version, + bool installed, + string dependenceName = "") + { + return new ModificationVersion + { + Name = name, + Version = version, + Installed = installed, + ModificationType = ModificationType.Mod, + DependenceName = dependenceName + }; + } + + private static void RunOnStaThread(Action action) + { + Exception? exception = null; + Thread thread = new(() => + { + try + { + action(); + } + catch (Exception caughtException) + { + exception = caughtException; + } + }); + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + + if (exception is not null) + { + ExceptionDispatchInfo.Capture(exception).Throw(); + } + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherManualImportCoordinatorTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherManualImportCoordinatorTests.cs new file mode 100644 index 00000000..07a9c421 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherManualImportCoordinatorTests.cs @@ -0,0 +1,350 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Models; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Launcher.Contracts; +using GenLauncherGO.UI.Features.Launcher.Models; +using GenLauncherGO.UI.Features.Launcher.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Services; + +public sealed class LauncherManualImportCoordinatorTests +{ + [Fact] + public void ImportAsyncWhenNoFilesAreSelectedReturnsNullWithoutShowingDialog() + { + StaTestRunner.Run(async () => + { + // Arrange + ILauncherFilePicker filePicker = CreateFilePicker(Array.Empty()); + ILauncherDialogService dialogService = Substitute.For(); + LauncherManualImportCoordinator coordinator = CreateCoordinator( + filePicker: filePicker, + dialogService: dialogService); + + // Act + LauncherManualImportResult? result = await coordinator.ImportAsync( + new LauncherManualImportRequest(LauncherManualImportKind.Modification, new Window()), + CancellationToken.None); + + // Assert + result.Should().BeNull(); + dialogService.DidNotReceive().ShowManualModificationImport( + Arg.Any(), + Arg.Any()); + }); + } + + [Fact] + public void ImportAsyncForModificationImportsToModVersionFolderAndRegistersSnapshot() + { + StaTestRunner.Run(async () => + { + // Arrange + string[] selectedFiles = { @"C:\Downloads\shockwave.zip" }; + ModificationVersion savedVersion = CreateVersion("Shockwave", "1.2", ModificationType.Mod); + GameModification savedModification = CreateModification(savedVersion); + ILauncherFilePicker filePicker = CreateFilePicker(selectedFiles); + ILauncherDialogService dialogService = CreateDialogService( + new ManualModificationDialogResult(selectedFiles, null, "Shockwave", "1.2")); + ILauncherContentCatalogQueries catalogQueries = CreateCatalogQueries(mods: new[] { savedModification }); + IManualModificationImporter importer = Substitute.For(); + ManualModificationImportRequest? capturedImport = null; + importer + .When(service => service.Import( + Arg.Do(request => capturedImport = request), + Arg.Any())) + .Do(_ => { }); + ILaunchContentIntegrityResolutionService resolutionService = + Substitute.For(); + LauncherManualImportCoordinator coordinator = CreateCoordinator( + filePicker, + dialogService, + catalogQueries, + manualModificationImporter: importer, + resolutionService: resolutionService); + + // Act + LauncherManualImportResult? result = await coordinator.ImportAsync( + new LauncherManualImportRequest(LauncherManualImportKind.Modification, new Window()), + CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result!.Kind.Should().Be(LauncherManualImportKind.Modification); + result.Modification.Should().BeSameAs(savedModification); + capturedImport.Should().NotBeNull(); + capturedImport!.SourceFilePaths.Should().Equal(selectedFiles); + capturedImport.DestinationDirectory.Should().Be(@"C:\Games\ZeroHour\GenLauncherGO\Mods\Shockwave\1.2"); + await resolutionService.Received(1).RegisterManualImportAsync( + Arg.Is(request => + ((LaunchContentIntegrityVersionRequest)request).Version == savedVersion), + Arg.Any()); + }); + } + + [Theory] + [InlineData("Patch", "Patches", "Balance Patch")] + [InlineData("Addon", "Addons", "HD Addon")] + public void ImportAsyncForChildContentUsesParentContentFolder( + string kindName, + string childFolderName, + string contentName) + { + StaTestRunner.Run(async () => + { + // Arrange + LauncherManualImportKind kind = Enum.Parse(kindName); + string[] selectedFiles = { @"C:\Downloads\child.zip" }; + ModificationType modificationType = kind == LauncherManualImportKind.Patch + ? ModificationType.Patch + : ModificationType.Addon; + ModificationVersion savedVersion = CreateVersion(contentName, "3.0", modificationType, "Shockwave"); + GameModification savedModification = CreateModification(savedVersion, "Shockwave"); + ILauncherDialogService dialogService = CreateDialogService( + new ManualModificationDialogResult(selectedFiles, "Shockwave", contentName, "3.0")); + ILauncherContentCatalogQueries catalogQueries = kind == LauncherManualImportKind.Patch + ? CreateCatalogQueries(patches: new[] { savedModification }) + : CreateCatalogQueries(addons: new[] { savedModification }); + IManualModificationImporter importer = Substitute.For(); + ManualModificationImportRequest? capturedImport = null; + importer + .When(service => service.Import( + Arg.Do(request => capturedImport = request), + Arg.Any())) + .Do(_ => { }); + LauncherManualImportCoordinator coordinator = CreateCoordinator( + CreateFilePicker(selectedFiles), + dialogService, + catalogQueries, + manualModificationImporter: importer); + + // Act + LauncherManualImportResult? result = await coordinator.ImportAsync( + new LauncherManualImportRequest(kind, new Window(), "Shockwave"), + CancellationToken.None); + + // Assert + result.Should().NotBeNull(); + result!.Kind.Should().Be(kind); + result.Modification.Should().BeSameAs(savedModification); + capturedImport.Should().NotBeNull(); + capturedImport!.DestinationDirectory.Should().Be( + $@"C:\Games\ZeroHour\GenLauncherGO\Mods\Shockwave\{childFolderName}\{contentName}\3.0"); + }); + } + + [Fact] + public void ImportAsyncForChildContentWithoutParentUsesOriginalGameFolder() + { + StaTestRunner.Run(async () => + { + // Arrange + string[] selectedFiles = { @"C:\Downloads\original-patch.zip" }; + ModificationVersion savedVersion = CreateVersion( + "Community Patch", + "1.0", + ModificationType.Patch, + "Original Game"); + GameModification savedModification = CreateModification(savedVersion, "Original Game"); + ILauncherDialogService dialogService = CreateDialogService( + new ManualModificationDialogResult(selectedFiles, null, "Community Patch", "1.0")); + ILauncherContentCatalogQueries catalogQueries = CreateCatalogQueries(patches: new[] { savedModification }); + IManualModificationImporter importer = Substitute.For(); + ManualModificationDialogRequest? capturedDialogRequest = null; + ManualModificationImportRequest? capturedImport = null; + dialogService.ShowManualModificationImport( + Arg.Do(request => capturedDialogRequest = request), + Arg.Any()) + .Returns(new ManualModificationDialogResult(selectedFiles, null, "Community Patch", "1.0")); + importer + .When(service => service.Import( + Arg.Do(request => capturedImport = request), + Arg.Any())) + .Do(_ => { }); + LauncherManualImportCoordinator coordinator = CreateCoordinator( + CreateFilePicker(selectedFiles), + dialogService, + catalogQueries, + manualModificationImporter: importer); + + // Act + await coordinator.ImportAsync( + new LauncherManualImportRequest(LauncherManualImportKind.Patch, new Window()), + CancellationToken.None); + + // Assert + capturedDialogRequest.Should().NotBeNull(); + capturedDialogRequest!.ParentContentName.Should().Be("Original Game"); + capturedImport.Should().NotBeNull(); + capturedImport!.DestinationDirectory.Should().Be( + @"C:\Games\ZeroHour\GenLauncherGO\Mods\Original Game\Patches\Community Patch\1.0"); + }); + } + + [Fact] + public void ImportAsyncWhenImportedVersionIsMissingThrows() + { + StaTestRunner.Run(async () => + { + // Arrange + string[] selectedFiles = { @"C:\Downloads\shockwave.zip" }; + GameModification savedModification = CreateModification( + CreateVersion("Shockwave", "1.1", ModificationType.Mod)); + LauncherManualImportCoordinator coordinator = CreateCoordinator( + CreateFilePicker(selectedFiles), + CreateDialogService(new ManualModificationDialogResult(selectedFiles, null, "Shockwave", "1.2")), + CreateCatalogQueries(mods: new[] { savedModification })); + + // Act + Func act = () => coordinator.ImportAsync( + new LauncherManualImportRequest(LauncherManualImportKind.Modification, new Window()), + CancellationToken.None); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("Imported Modification 'Shockwave' version '1.2' was not found*"); + }); + } + + private static LauncherManualImportCoordinator CreateCoordinator( + ILauncherFilePicker? filePicker = null, + ILauncherDialogService? dialogService = null, + ILauncherContentCatalogQueries? catalogQueries = null, + ILauncherContentCatalogCommands? catalogCommands = null, + IManualModificationImporter? manualModificationImporter = null, + ILaunchContentIntegrityResolutionService? resolutionService = null) + { + ILauncherContentCatalogQueries resolvedCatalogQueries = catalogQueries ?? CreateCatalogQueries(); + return new LauncherManualImportCoordinator( + filePicker ?? CreateFilePicker(Array.Empty()), + dialogService ?? Substitute.For(), + resolvedCatalogQueries, + catalogCommands ?? Substitute.For(), + CreatePaths(), + new LauncherContentLayout("Addons", "Patches"), + manualModificationImporter ?? Substitute.For(), + CreateIntegrityCoordinator(resolutionService, resolvedCatalogQueries), + NullLogger.Instance); + } + + private static LaunchContentIntegrityCoordinator CreateIntegrityCoordinator( + ILaunchContentIntegrityResolutionService? resolutionService, + ILauncherContentCatalogQueries catalogQueries) + { + return new LaunchContentIntegrityCoordinator( + resolutionService ?? Substitute.For(), + catalogQueries, + CreatePaths(), + new LauncherContentLayout("Addons", "Patches"), + new LauncherPackageActivityService(), + new TestStringLocalizer(new Dictionary + { + ["IntegrityCacheSuffix"] = " cache" + }), + Substitute.For(), + NullLogger.Instance); + } + + private static ILauncherFilePicker CreateFilePicker(IReadOnlyList selectedFiles) + { + return new StubLauncherFilePicker(selectedFiles); + } + + private static ILauncherDialogService CreateDialogService(ManualModificationDialogResult result) + { + ILauncherDialogService dialogService = Substitute.For(); + dialogService.ShowManualModificationImport( + Arg.Any(), + Arg.Any()) + .Returns(result); + return dialogService; + } + + private static ILauncherContentCatalogQueries CreateCatalogQueries( + IReadOnlyList? mods = null, + IReadOnlyList? patches = null, + IReadOnlyList? addons = null) + { + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetMods().Returns(mods ?? Array.Empty()); + catalogQueries.GetPatchesForSelectedMod().Returns(patches ?? Array.Empty()); + catalogQueries.GetAddonsForSelectedMod().Returns(addons ?? Array.Empty()); + catalogQueries.GetAllModsVersionsList().Returns(Array.Empty()); + return catalogQueries; + } + + private static GameModification CreateModification( + ModificationVersion version, + string dependenceName = "") + { + return new GameModification(version) + { + Name = version.Name, + ModificationType = version.ModificationType, + DependenceName = dependenceName, + ModificationVersions = new List { version } + }; + } + + private static ModificationVersion CreateVersion( + string name, + string version, + ModificationType modificationType, + string dependenceName = "") + { + return new ModificationVersion + { + Name = name, + Version = version, + ModificationType = modificationType, + DependenceName = dependenceName, + ContentSourceKind = GenLauncherGO.Core.Integrity.Models.ContentSourceKind.Manual + }; + } + + private static LauncherPaths CreatePaths() + { + return new LauncherPaths( + @"C:\Games\ZeroHour", + @"C:\Games\ZeroHour\GenLauncherGO", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache\Images", + @"C:\Games\ZeroHour\GenLauncherGO\Mods", + @"C:\Games\ZeroHour\GenLauncherGO\Logs", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Temp", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Deployment"); + } + + private sealed class StubLauncherFilePicker : ILauncherFilePicker + { + private readonly IReadOnlyList _selectedFiles; + + public StubLauncherFilePicker(IReadOnlyList selectedFiles) + { + _selectedFiles = selectedFiles; + } + + public IReadOnlyList PickManualPackageFiles(Window owner) + { + return _selectedFiles; + } + + public string? PickModificationImageFile(Window owner, string imageFilterLabel) + { + return null; + } + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherModificationDownloadCoordinatorTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherModificationDownloadCoordinatorTests.cs new file mode 100644 index 00000000..086f5358 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherModificationDownloadCoordinatorTests.cs @@ -0,0 +1,605 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Settings.Contracts; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Models; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Mods; +using GenLauncherGO.UI.Features.Mods.Contracts; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Services; + +public sealed class LauncherModificationDownloadCoordinatorTests +{ + [Fact] + public void StartDownloadAsyncWhenPackageActivityIsActiveShowsInfoAndDoesNotCreateOperation() + { + StaTestRunner.Run(async () => + { + // Arrange + LauncherPackageActivityService packageActivityService = new(); + packageActivityService.TryBegin("Existing", out LauncherPackageActivityService.LauncherPackageActivityLease? lease) + .Should().BeTrue(); + ILauncherDialogService dialogService = Substitute.For(); + IPackageDownloadOperationFactory factory = Substitute.For(); + LauncherModificationDownloadCoordinator coordinator = CreateCoordinator( + factory, + packageActivityService: packageActivityService, + dialogService: dialogService); + ModificationViewModel viewModel = CreateViewModel(); + + try + { + // Act + await coordinator.StartDownloadAsync(viewModel, new Window(), () => { }); + + // Assert + coordinator.ActiveDownloadCount.Should().Be(0); + viewModel.Downloader.Should().BeNull(); + viewModel.UpdateButtonEnabled.Should().BeTrue(); + factory.DidNotReceive().Create( + Arg.Any(), + Arg.Any()); + dialogService.Received(1).ShowInfo( + Arg.Is(request => + request.MainMessage == "Package activity" && + request.DetailMessage == "Package activity details"), + Arg.Any()); + } + finally + { + lease?.Dispose(); + } + }); + } + + [Fact] + public void StartDownloadAsyncWhenReadinessFailsReleasesActivityAndRestoresTile() + { + StaTestRunner.Run(async () => + { + // Arrange + FakePackageDownloadOperation operation = new() + { + Readiness = new PackageDownloadReadiness + { + ReadyToDownload = false, + Error = PackageDownloadReadinessError.Unknown + } + }; + LauncherPackageActivityService packageActivityService = new(); + LauncherModificationDownloadCoordinator coordinator = CreateCoordinator( + CreateFactory(operation), + packageActivityService: packageActivityService); + ModificationViewModel viewModel = CreateViewModel(); + int stateChangedCount = 0; + + // Act + await coordinator.StartDownloadAsync(viewModel, new Window(), () => stateChangedCount++); + + // Assert + coordinator.ActiveDownloadCount.Should().Be(0); + packageActivityService.IsActive.Should().BeFalse(); + viewModel.Downloader.Should().BeNull(); + viewModel.ProgressMessage.Should().Be("Error: Unknown"); + viewModel.UpdateButtonEnabled.Should().BeTrue(); + operation.StartCount.Should().Be(0); + stateChangedCount.Should().BeGreaterThanOrEqualTo(2); + }); + } + + [Fact] + public void StartDownloadAsyncWhenTimeIsOutOfSyncAndSyncIsDeclinedRejectsDownload() + { + StaTestRunner.Run(async () => + { + // Arrange + FakePackageDownloadOperation operation = new() + { + Readiness = new PackageDownloadReadiness + { + ReadyToDownload = false, + Error = PackageDownloadReadinessError.TimeOutOfSync + } + }; + ILauncherDialogService dialogService = Substitute.For(); + dialogService.ShowWarningConfirmation( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(false); + ISystemClockService systemClockService = Substitute.For(); + LauncherModificationDownloadCoordinator coordinator = CreateCoordinator( + CreateFactory(operation), + dialogService: dialogService, + systemClockService: systemClockService); + ModificationViewModel viewModel = CreateViewModel(); + + // Act + await coordinator.StartDownloadAsync(viewModel, new Window(), () => { }); + + // Assert + coordinator.ActiveDownloadCount.Should().Be(0); + viewModel.Downloader.Should().BeNull(); + viewModel.ProgressMessage.Should().Be("Error: Out of sync"); + operation.StartCount.Should().Be(0); + systemClockService.DidNotReceive().TrySynchronizeSystemTimeWithNetworkTime(); + dialogService.Received(1).ShowWarningConfirmation( + Arg.Is(request => + request.MainMessage == "Out of sync" && + request.DetailMessage == "Sync time"), + "Sync now", + Arg.Any()); + }); + } + + [Fact] + public void StartDownloadAsyncWhenTimeSyncSucceedsChecksReadinessAgainAndStartsDownload() + { + StaTestRunner.Run(async () => + { + // Arrange + FakePackageDownloadOperation operation = new(); + operation.ReadinessResponses.Enqueue(new PackageDownloadReadiness + { + ReadyToDownload = false, + Error = PackageDownloadReadinessError.TimeOutOfSync + }); + operation.ReadinessResponses.Enqueue(new PackageDownloadReadiness + { + ReadyToDownload = true + }); + ILauncherDialogService dialogService = Substitute.For(); + dialogService.ShowWarningConfirmation( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(true); + ISystemClockService systemClockService = Substitute.For(); + systemClockService.IsSystemTimeOutOfSync().Returns(false); + LauncherModificationDownloadCoordinator coordinator = CreateCoordinator( + CreateFactory(operation), + dialogService: dialogService, + systemClockService: systemClockService); + ModificationViewModel viewModel = CreateViewModel(); + + // Act + await coordinator.StartDownloadAsync(viewModel, new Window(), () => { }); + + // Assert + coordinator.ActiveDownloadCount.Should().Be(1); + operation.StartCount.Should().Be(1); + systemClockService.Received(1).TrySynchronizeSystemTimeWithNetworkTime(); + viewModel.Downloader.Should().BeSameAs(operation); + }); + } + + [Fact] + public void StartDownloadAsyncWhenTimeSyncStillFailsShowsErrorAndRejectsDownload() + { + StaTestRunner.Run(async () => + { + // Arrange + FakePackageDownloadOperation operation = new() + { + Readiness = new PackageDownloadReadiness + { + ReadyToDownload = false, + Error = PackageDownloadReadinessError.TimeOutOfSync + } + }; + ILauncherDialogService dialogService = Substitute.For(); + dialogService.ShowWarningConfirmation( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(true); + ISystemClockService systemClockService = Substitute.For(); + systemClockService.IsSystemTimeOutOfSync().Returns(true); + LauncherModificationDownloadCoordinator coordinator = CreateCoordinator( + CreateFactory(operation), + dialogService: dialogService, + systemClockService: systemClockService); + ModificationViewModel viewModel = CreateViewModel(); + + // Act + await coordinator.StartDownloadAsync(viewModel, new Window(), () => { }); + + // Assert + coordinator.ActiveDownloadCount.Should().Be(0); + viewModel.Downloader.Should().BeNull(); + viewModel.ProgressMessage.Should().Be("Error: Out of sync"); + operation.StartCount.Should().Be(0); + dialogService.Received(1).ShowError( + Arg.Is(request => + request.MainMessage == "Can't sync" && + request.DetailMessage == "Sync manually"), + Arg.Any()); + }); + } + + [Fact] + public void StartDownloadAsyncWhenOperationThrowsRestoresTileAndReleasesActivity() + { + StaTestRunner.Run(async () => + { + // Arrange + FakePackageDownloadOperation operation = new() + { + StartException = new InvalidOperationException("start failed") + }; + LauncherPackageActivityService packageActivityService = new(); + LauncherModificationDownloadCoordinator coordinator = CreateCoordinator( + CreateFactory(operation), + packageActivityService: packageActivityService); + ModificationViewModel viewModel = CreateViewModel(); + int stateChangedCount = 0; + + // Act + await coordinator.StartDownloadAsync(viewModel, new Window(), () => stateChangedCount++); + + // Assert + coordinator.ActiveDownloadCount.Should().Be(0); + packageActivityService.IsActive.Should().BeFalse(); + viewModel.Downloader.Should().BeNull(); + viewModel.ProgressMessage.Should().Be("start failed"); + viewModel.UpdateButtonEnabled.Should().BeTrue(); + stateChangedCount.Should().BeGreaterThanOrEqualTo(2); + }); + } + + [Fact] + public void StartDownloadAsyncWhenProgressChangesFormatsTileProgress() + { + StaTestRunner.Run(async () => + { + // Arrange + FakePackageDownloadOperation operation = new(); + LauncherPackageActivityService packageActivityService = new(); + LauncherModificationDownloadCoordinator coordinator = CreateCoordinator( + CreateFactory(operation), + packageActivityService: packageActivityService); + ModificationViewModel viewModel = CreateViewModel(); + int stateChangedCount = 0; + + // Act + await coordinator.StartDownloadAsync(viewModel, new Window(), () => stateChangedCount++); + operation.ReportProgress(new PackageUpdateProgress(10485760, 2621440, 25, "package.big")); + + // Assert + coordinator.ActiveDownloadCount.Should().Be(1); + viewModel.Downloader.Should().BeSameAs(operation); + viewModel.ProgressMessage.Should().Be("Downloaded 2.5 MB of 10.0 MB"); + viewModel.ProgressValue.Should().Be(25); + packageActivityService.ProgressPercentage.Should().Be(25); + stateChangedCount.Should().BeGreaterThanOrEqualTo(2); + }); + } + + [Theory] + [InlineData(true, false, "timed out")] + [InlineData(false, true, "crashed")] + public void DownloadCompletionWhenOperationFailsRestoresTileAndShowsError( + bool timedOut, + bool crashed, + string message) + { + StaTestRunner.Run(async () => + { + // Arrange + FakePackageDownloadOperation operation = new(); + LauncherPackageActivityService packageActivityService = new(); + LauncherModificationDownloadCoordinator coordinator = CreateCoordinator( + CreateFactory(operation), + packageActivityService: packageActivityService); + ModificationViewModel viewModel = CreateViewModel(); + + // Act + await coordinator.StartDownloadAsync(viewModel, new Window(), () => { }); + operation.Complete(new PackageDownloadResult + { + TimedOut = timedOut, + Crashed = crashed, + Message = message + }); + WaitUntil(() => coordinator.ActiveDownloadCount == 0); + + // Assert + packageActivityService.IsActive.Should().BeFalse(); + viewModel.Downloader.Should().BeNull(); + viewModel.ProgressMessage.Should().Be("Error: " + message); + viewModel.ProgressValue.Should().Be(0); + }); + } + + [Fact] + public void DownloadCompletionWhenCanceledRestoresTileAndReleasesActivity() + { + StaTestRunner.Run(async () => + { + // Arrange + FakePackageDownloadOperation operation = new(); + LauncherPackageActivityService packageActivityService = new(); + LauncherModificationDownloadCoordinator coordinator = CreateCoordinator( + CreateFactory(operation), + packageActivityService: packageActivityService); + ModificationViewModel viewModel = CreateViewModel(); + + // Act + await coordinator.StartDownloadAsync(viewModel, new Window(), () => { }); + operation.Complete(new PackageDownloadResult { Canceled = true }); + WaitUntil(() => coordinator.ActiveDownloadCount == 0); + + // Assert + packageActivityService.IsActive.Should().BeFalse(); + viewModel.Downloader.Should().BeNull(); + viewModel.ProgressMessage.Should().Be("Canceled"); + viewModel.ProgressValue.Should().Be(0); + }); + } + + [Fact] + public void SuccessfulDownloadUpdatesCatalogDeletesOldVersionsAndCapturesSnapshot() + { + StaTestRunner.Run(async () => + { + // Arrange + ModificationVersion oldVersion = CreateVersion("1.0", installed: true); + ModificationVersion latestVersion = CreateVersion("2.0", installed: false); + GameModification modification = CreateModification(oldVersion, latestVersion); + ModificationViewModel viewModel = CreateViewModel(modification); + FakePackageDownloadOperation operation = new(); + ILauncherContentCatalogCommands catalogCommands = Substitute.For(); + ILaunchContentIntegrityResolutionService resolutionService = + Substitute.For(); + LauncherPackageActivityService packageActivityService = new(); + LauncherModificationDownloadCoordinator coordinator = CreateCoordinator( + CreateFactory(operation), + catalogCommands, + CreatePreferencesService(new LauncherPreferences { AutoDeleteOldVersions = true }), + packageActivityService, + resolutionService: resolutionService); + int stateChangedCount = 0; + + // Act + await coordinator.StartDownloadAsync(viewModel, new Window(), () => stateChangedCount++); + operation.Complete(new PackageDownloadResult()); + WaitUntil(() => coordinator.ActiveDownloadCount == 0); + + // Assert + packageActivityService.IsActive.Should().BeFalse(); + modification.Installed.Should().BeTrue(); + viewModel.Downloader.Should().BeNull(); + viewModel.HasActivePackageActivity.Should().BeFalse(); + catalogCommands.Received(1).DeleteVersion(oldVersion); + catalogCommands.Received(1).UpdateLocalModificationsData(); + await resolutionService.Received(1).CaptureManagedInstallSnapshotAsync( + Arg.Any(), + Arg.Any()); + stateChangedCount.Should().BeGreaterThanOrEqualTo(3); + }); + } + + private static LauncherModificationDownloadCoordinator CreateCoordinator( + IPackageDownloadOperationFactory factory, + ILauncherContentCatalogCommands? catalogCommands = null, + ILauncherPreferencesService? preferencesService = null, + LauncherPackageActivityService? packageActivityService = null, + ILauncherDialogService? dialogService = null, + ILaunchContentIntegrityResolutionService? resolutionService = null, + ISystemClockService? systemClockService = null) + { + LauncherPackageActivityService resolvedPackageActivityService = packageActivityService ?? new(); + ILauncherDialogService resolvedDialogService = dialogService ?? Substitute.For(); + + return new LauncherModificationDownloadCoordinator( + preferencesService ?? CreatePreferencesService(new LauncherPreferences()), + catalogCommands ?? Substitute.For(), + factory, + systemClockService ?? Substitute.For(), + CreateIntegrityCoordinator( + resolutionService ?? Substitute.For(), + resolvedPackageActivityService, + resolvedDialogService), + resolvedPackageActivityService, + resolvedDialogService, + CreateStringLocalizer(), + NullLogger.Instance); + } + + private static IPackageDownloadOperationFactory CreateFactory(FakePackageDownloadOperation operation) + { + IPackageDownloadOperationFactory factory = Substitute.For(); + factory.Create(Arg.Any(), Arg.Any()).Returns(operation); + return factory; + } + + private static ILauncherPreferencesService CreatePreferencesService(LauncherPreferences preferences) + { + ILauncherPreferencesService preferencesService = Substitute.For(); + preferencesService.Current.Returns(preferences); + return preferencesService; + } + + private static LaunchContentIntegrityCoordinator CreateIntegrityCoordinator( + ILaunchContentIntegrityResolutionService resolutionService, + LauncherPackageActivityService packageActivityService, + ILauncherDialogService dialogService) + { + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetAllModsVersionsList().Returns(Array.Empty()); + return new LaunchContentIntegrityCoordinator( + resolutionService, + catalogQueries, + CreatePaths(), + new LauncherContentLayout("Addons", "Patches"), + packageActivityService, + CreateStringLocalizer(), + dialogService, + NullLogger.Instance); + } + + private static ModificationViewModel CreateViewModel() + { + return CreateViewModel(CreateModification(CreateVersion("1.0", installed: false))); + } + + private static ModificationViewModel CreateViewModel(GameModification modification) + { + return new ModificationViewModel( + modification, + new ModificationImageSourceFactory(NullLogger.Instance), + new TestLauncherModsContext(), + Substitute.For(), + CreateStringLocalizer(), + NullLogger.Instance); + } + + private static GameModification CreateModification(params ModificationVersion[] versions) + { + return new GameModification + { + Name = "Shockwave", + ModificationType = ModificationType.Mod, + ModificationVersions = new List(versions) + }; + } + + private static ModificationVersion CreateVersion(string version, bool installed) + { + return new ModificationVersion + { + Name = "Shockwave", + Version = version, + Installed = installed, + ModificationType = ModificationType.Mod, + ContentSourceKind = GenLauncherGO.Core.Integrity.Models.ContentSourceKind.ManagedSingleFile, + SimpleDownloadLink = "https://example.test/shockwave.zip" + }; + } + + private static TestStringLocalizer CreateStringLocalizer() + { + return new TestStringLocalizer(new Dictionary + { + ["AdvertisingDonationAlerts"] = "Donate", + ["Cancel"] = "Cancel", + ["Canceled"] = "Canceled", + ["DownloadInProgress"] = "Downloaded {0} MB of {1} MB", + ["Error"] = "Error: ", + ["Install"] = "Install", + ["IntegrityCacheSuffix"] = " cache", + ["LatestVersion"] = "Latest version: ", + ["CantSync"] = "Can't sync", + ["OutOfSync"] = "Out of sync", + ["OutOfSyncCantUpdate"] = "Out of sync", + ["PackageActivityInProgress"] = "Package activity", + ["PackageActivityInProgressDetails"] = "Package activity details", + ["Preparing"] = "Preparing", + ["SyncManually"] = "Sync manually", + ["SyncNow"] = "Sync now", + ["SyncTime"] = "Sync time", + ["UnpackingPreparing"] = "Unpacking", + ["Update"] = "Update", + ["UpToDate"] = "Up to date", + }); + } + + private static void WaitUntil(Func condition) + { + SpinWait.SpinUntil(condition, TimeSpan.FromSeconds(2)).Should().BeTrue(); + } + + private static LauncherPaths CreatePaths() + { + return new LauncherPaths( + @"C:\Games\ZeroHour", + @"C:\Games\ZeroHour\GenLauncherGO", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache\Images", + @"C:\Games\ZeroHour\GenLauncherGO\Mods", + @"C:\Games\ZeroHour\GenLauncherGO\Logs", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Temp", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Deployment"); + } + + private sealed class FakePackageDownloadOperation : IPackageDownloadOperation + { + public event Action? ProgressChanged; + + public event Action? Done; + + public PackageDownloadReadiness Readiness { get; init; } = new() + { + ReadyToDownload = true + }; + + public Queue ReadinessResponses { get; } = new(); + + public Exception? StartException { get; init; } + + public int StartCount { get; private set; } + + public PackageDownloadResult Result { get; private set; } = new(); + + public PackageDownloadReadiness GetPackageDownloadReadiness() + { + if (ReadinessResponses.Count > 0) + { + return ReadinessResponses.Dequeue(); + } + + return Readiness; + } + + public Task StartDownloadModificationAsync() + { + if (StartException != null) + { + throw StartException; + } + + StartCount++; + return Task.CompletedTask; + } + + public void CancelDownload() + { + Result = new PackageDownloadResult { Canceled = true }; + } + + public PackageDownloadResult GetResult() + { + return Result; + } + + public void ReportProgress(PackageUpdateProgress progress) + { + ProgressChanged?.Invoke(progress); + } + + public void Complete(PackageDownloadResult result) + { + Result = result; + Done?.Invoke(result); + } + + public void Dispose() + { + } + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherSelectedContentServiceTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherSelectedContentServiceTests.cs new file mode 100644 index 00000000..8be02736 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherSelectedContentServiceTests.cs @@ -0,0 +1,129 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Mods; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Services; + +public sealed class LauncherSelectedContentServiceTests +{ + [Fact] + public void GetSelectedVersionsReturnsSelectedVersionsInLaunchOrder() + { + // Arrange + ModificationVersion selectedMod = CreateVersion("Mod"); + ModificationVersion selectedPatch = CreateVersion("Patch"); + ModificationVersion selectedAddon = CreateVersion("Addon"); + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedModVersion().Returns(selectedMod); + catalogQueries.GetSelectedPatchVersion().Returns(selectedPatch); + catalogQueries.GetSelectedAddonsVersions().Returns(new List + { + selectedAddon + }); + LauncherSelectedContentService service = new(catalogQueries); + + // Act + IReadOnlyList result = service.GetSelectedVersions(); + + // Assert + result.Should().Equal(selectedMod, selectedPatch, selectedAddon); + } + + [Fact] + public void GetSelectedVersionsSkipsNullSelectedVersions() + { + // Arrange + ModificationVersion selectedAddon = CreateVersion("Addon"); + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedAddonsVersions().Returns(new List + { + null!, + selectedAddon + }); + LauncherSelectedContentService service = new(catalogQueries); + + // Act + IReadOnlyList result = service.GetSelectedVersions(); + + // Assert + result.Should().Equal(selectedAddon); + } + + [Fact] + public void GetSelectedModificationViewModelsFiltersNonTileItems() + { + // Arrange + ModificationViewModel first = CreateViewModel("First"); + ModificationViewModel second = CreateViewModel("Second"); + object[] selectedItems = + { + first, + "not a tile", + second + }; + + // Act + IReadOnlyList result = + LauncherSelectedContentService.GetSelectedModificationViewModels(selectedItems); + + // Assert + result.Should().Equal(first, second); + } + + [Fact] + public void GetIntegrityProgressTargetsUsesFirstModFirstPatchAndAllAddons() + { + // Arrange + ModificationViewModel firstMod = CreateViewModel("First Mod"); + ModificationViewModel ignoredMod = CreateViewModel("Ignored Mod"); + ModificationViewModel firstPatch = CreateViewModel("First Patch"); + ModificationViewModel addonOne = CreateViewModel("Addon One"); + ModificationViewModel addonTwo = CreateViewModel("Addon Two"); + + // Act + IReadOnlyList result = + LauncherSelectedContentService.GetIntegrityProgressTargets( + new[] { firstMod, ignoredMod }, + new[] { firstPatch }, + new[] { addonOne, addonTwo }); + + // Assert + result.Should().Equal(firstMod, firstPatch, addonOne, addonTwo); + } + + private static ModificationVersion CreateVersion(string name) + { + return new ModificationVersion + { + Name = name, + Version = "1.0", + ModificationType = ModificationType.Mod + }; + } + + private static ModificationViewModel CreateViewModel(string name) + { + GameModification modification = new() + { + Name = name, + ModificationType = ModificationType.Mod, + ModificationVersions = new List + { + CreateVersion(name) + } + }; + + return new ModificationViewModel( + modification, + new ModificationImageSourceFactory(NullLogger.Instance), + new TestLauncherModsContext(), + Substitute.For(), + new TestStringLocalizer(), + NullLogger.Instance); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherShellNavigationCoordinatorTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherShellNavigationCoordinatorTests.cs new file mode 100644 index 00000000..b8331d79 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherShellNavigationCoordinatorTests.cs @@ -0,0 +1,286 @@ +using System; +using System.Collections.Generic; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Windows; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Mods.Services; +using GenLauncherGO.Core.Shell.Contracts; +using GenLauncherGO.Core.Shell.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Models; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Mods; +using GenLauncherGO.UI.Features.Mods.Contracts; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Services; + +public sealed class LauncherShellNavigationCoordinatorTests +{ + [Fact] + public void OpenUriIfPresentIgnoresMissingUri() + { + // Arrange + ILauncherShellService shellService = Substitute.For(); + LauncherShellNavigationCoordinator coordinator = CreateCoordinator(shellService: shellService); + + // Act + coordinator.OpenUriIfPresent(" "); + + // Assert + shellService.DidNotReceiveWithAnyArgs().OpenUri(default!); + } + + [Fact] + public void OpenUriIfPresentOpensNonEmptyUri() + { + // Arrange + ILauncherShellService shellService = Substitute.For(); + shellService.OpenUri("https://example.test").Returns(ShellOpenResult.Success("https://example.test")); + LauncherShellNavigationCoordinator coordinator = CreateCoordinator(shellService: shellService); + + // Act + coordinator.OpenUriIfPresent("https://example.test"); + + // Assert + shellService.Received(1).OpenUri("https://example.test"); + } + + [Fact] + public void OpenGameFolderShowsPathErrorWhenShellCannotOpenFolder() + { + RunOnStaThread(() => + { + // Arrange + LauncherPaths paths = CreatePaths(); + ILauncherShellService shellService = Substitute.For(); + shellService.OpenFolder(paths.GameDirectory, false) + .Returns(ShellOpenResult.Failure( + ShellOpenFailureKind.MissingTarget, + paths.GameDirectory, + "missing")); + ILauncherDialogService dialogService = Substitute.For(); + LauncherShellNavigationCoordinator coordinator = CreateCoordinator( + paths, + shellService: shellService, + dialogService: dialogService); + Window owner = new(); + + // Act + coordinator.OpenGameFolder(owner); + + // Assert + dialogService.Received(1).ShowError( + Arg.Is(request => + request.MainMessage == "Cannot find path" && + request.DetailMessage == paths.GameDirectory), + owner); + }); + } + + [Fact] + public void OpenModificationFolderOpensSelectedVersionFolder() + { + RunOnStaThread(() => + { + // Arrange + LauncherPaths paths = CreatePaths(); + ModificationVersion selectedVersion = CreateVersion("ShockWave", "1.2", ModificationType.Mod); + selectedVersion.IsSelected = true; + ModificationViewModel viewModel = CreateViewModel(selectedVersion); + string expectedPath = System.IO.Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"); + ILauncherShellService shellService = Substitute.For(); + shellService.OpenFolder(expectedPath, true).Returns(ShellOpenResult.Success(expectedPath)); + LauncherShellNavigationCoordinator coordinator = CreateCoordinator(paths, shellService: shellService); + Window owner = new(); + + // Act + coordinator.OpenModificationFolder(viewModel, owner); + + // Assert + shellService.Received(1).OpenFolder(expectedPath, true); + }); + } + + [Theory] + [InlineData(ModificationType.Mod, "Uninstalled mod", "Need to install mod")] + [InlineData(ModificationType.Addon, "Uninstalled addon", "Need to install addon")] + [InlineData(ModificationType.Patch, "Uninstalled patch", "Need to install patch")] + public void OpenModificationFolderShowsMissingInstalledContentDialog( + ModificationType modificationType, + string expectedMainMessage, + string expectedDetailMessage) + { + RunOnStaThread(() => + { + // Arrange + LauncherPaths paths = CreatePaths(); + ModificationVersion selectedVersion = CreateVersion("Content", "1.0", modificationType, "ShockWave"); + selectedVersion.IsSelected = true; + ModificationViewModel viewModel = CreateViewModel(selectedVersion); + string expectedPath = new LauncherContentPathResolver().GetVersionDirectoryPath( + paths, + Layout, + selectedVersion); + ILauncherShellService shellService = Substitute.For(); + shellService.OpenFolder(expectedPath, true).Returns(ShellOpenResult.Failure( + ShellOpenFailureKind.MissingTarget, + expectedPath, + "missing")); + ILauncherDialogService dialogService = Substitute.For(); + LauncherShellNavigationCoordinator coordinator = CreateCoordinator( + paths, + shellService: shellService, + dialogService: dialogService); + Window owner = new(); + + // Act + coordinator.OpenModificationFolder(viewModel, owner); + + // Assert + dialogService.Received(1).ShowError( + Arg.Is(request => + request.MainMessage == expectedMainMessage && + request.DetailMessage == expectedDetailMessage), + owner); + }); + } + + [Fact] + public void OpenModificationFolderShowsPathErrorForNonMissingFailure() + { + RunOnStaThread(() => + { + // Arrange + LauncherPaths paths = CreatePaths(); + ModificationVersion selectedVersion = CreateVersion("ShockWave", "1.2", ModificationType.Mod); + selectedVersion.IsSelected = true; + ModificationViewModel viewModel = CreateViewModel(selectedVersion); + string expectedPath = System.IO.Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"); + ILauncherShellService shellService = Substitute.For(); + shellService.OpenFolder(expectedPath, true).Returns(ShellOpenResult.Failure( + ShellOpenFailureKind.LaunchFailed, + expectedPath, + "rejected")); + ILauncherDialogService dialogService = Substitute.For(); + LauncherShellNavigationCoordinator coordinator = CreateCoordinator( + paths, + shellService: shellService, + dialogService: dialogService); + Window owner = new(); + + // Act + coordinator.OpenModificationFolder(viewModel, owner); + + // Assert + dialogService.Received(1).ShowError( + Arg.Is(request => + request.MainMessage == "Cannot find path" && + request.DetailMessage == expectedPath), + owner); + }); + } + + private static LauncherContentLayout Layout { get; } = new("Addons", "Patches"); + + private static LauncherShellNavigationCoordinator CreateCoordinator( + LauncherPaths? launcherPaths = null, + ILauncherShellService? shellService = null, + ILauncherDialogService? dialogService = null) + { + return new LauncherShellNavigationCoordinator( + launcherPaths ?? CreatePaths(), + Layout, + new LauncherContentPathResolver(), + new TestLauncherModsContext(), + new TestStringLocalizer(new Dictionary + { + ["CannotFindPath"] = "Cannot find path", + ["UninstalledMod"] = "Uninstalled mod", + ["NeedToInstallMod"] = "Need to install mod", + ["UninstalledAddon"] = "Uninstalled addon", + ["NeedToInstallAddon"] = "Need to install addon", + ["UninstalledPatch"] = "Uninstalled patch", + ["NeedToInstallPatch"] = "Need to install patch", + }), + shellService ?? Substitute.For(), + dialogService ?? Substitute.For(), + NullLogger.Instance); + } + + private static ModificationViewModel CreateViewModel(ModificationVersion version) + { + return new ModificationViewModel( + new GameModification + { + Name = version.Name, + ModificationType = version.ModificationType, + DependenceName = version.DependenceName, + ModificationVersions = new List { version } + }, + new ModificationImageSourceFactory(NullLogger.Instance), + new TestLauncherModsContext(), + Substitute.For(), + new TestStringLocalizer(), + NullLogger.Instance); + } + + private static ModificationVersion CreateVersion( + string name, + string version, + ModificationType modificationType, + string dependenceName = "") + { + return new ModificationVersion + { + Name = name, + Version = version, + ModificationType = modificationType, + DependenceName = dependenceName, + Installed = true + }; + } + + private static LauncherPaths CreatePaths() + { + return new LauncherPaths( + @"C:\Games\ZeroHour", + @"C:\Games\ZeroHour\GenLauncherGO", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache\Images", + @"C:\Games\ZeroHour\GenLauncherGO\Mods", + @"C:\Games\ZeroHour\GenLauncherGO\Logs", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Temp", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Deployment"); + } + + private static void RunOnStaThread(Action action) + { + Exception? exception = null; + Thread thread = new(() => + { + try + { + action(); + } + catch (Exception caughtException) + { + exception = caughtException; + } + }); + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + + if (exception is not null) + { + ExceptionDispatchInfo.Capture(exception).Throw(); + } + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherTabStateServiceTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherTabStateServiceTests.cs new file mode 100644 index 00000000..8e43f031 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherTabStateServiceTests.cs @@ -0,0 +1,110 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Launcher.Models; +using GenLauncherGO.UI.Features.Launcher.Services; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Services; + +public sealed class LauncherTabStateServiceTests +{ + [Fact] + public void GetCurrentStateBuildsSelectedModificationLabelsWithActiveCounts() + { + // Arrange + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedMod().Returns(CreateModification("Shockwave", ModificationType.Mod)); + catalogQueries.GetSelectedPatchVersion().Returns(CreateVersion(installed: true)); + catalogQueries.GetSelectedAddonsVersions().Returns(new List + { + CreateVersion(installed: true), + CreateVersion(installed: false), + CreateVersion(installed: true) + }); + LauncherTabStateService service = CreateService(catalogQueries, SupportedGame.ZeroHour); + + // Act + LauncherContentTabState result = service.GetCurrentState(); + + // Assert + result.ShowChildContentTabs.Should().BeTrue(); + result.PatchesTabText.Should().Be("Patches: Shockwave (1)"); + result.AddonsTabText.Should().Be("Addons: Shockwave (2)"); + result.ManualAddPatchText.Should().Be("Add patch for Shockwave"); + result.ManualAddAddonText.Should().Be("Add addon for Shockwave"); + } + + [Fact] + public void GetCurrentStateUsesManagedGameWhenNoModificationIsSelected() + { + // Arrange + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedAddonsVersions().Returns(System.Array.Empty()); + LauncherTabStateService service = CreateService(catalogQueries, SupportedGame.Generals); + + // Act + LauncherContentTabState result = service.GetCurrentState(); + + // Assert + result.ShowChildContentTabs.Should().BeTrue(); + result.PatchesTabText.Should().Be("Patches: Generals"); + result.AddonsTabText.Should().Be("Addons: Generals"); + result.ManualAddPatchText.Should().Be("Add patch for Generals"); + result.ManualAddAddonText.Should().Be("Add addon for Generals"); + } + + [Fact] + public void GetCurrentStateHidesChildTabsForAdvertising() + { + // Arrange + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedMod().Returns(CreateModification("Sponsor", ModificationType.Advertising)); + LauncherTabStateService service = CreateService(catalogQueries, SupportedGame.ZeroHour); + + // Act + LauncherContentTabState result = service.GetCurrentState(); + + // Assert + result.ShowChildContentTabs.Should().BeFalse(); + result.PatchesTabText.Should().BeEmpty(); + result.AddonsTabText.Should().BeEmpty(); + result.ManualAddPatchText.Should().BeEmpty(); + result.ManualAddAddonText.Should().BeEmpty(); + } + + private static LauncherTabStateService CreateService( + ILauncherContentCatalogQueries catalogQueries, + SupportedGame supportedGame) + { + return new LauncherTabStateService( + catalogQueries, + new TestLauncherModsContext(supportedGame), + new TestStringLocalizer(new Dictionary + { + ["Patches"] = "Patches: ", + ["Addons"] = "Addons: ", + ["AddPatchFromFiles"] = "Add patch for {0}", + ["AddAddonFromFiles"] = "Add addon for {0}", + })); + } + + private static GameModification CreateModification(string name, ModificationType modificationType) + { + return new GameModification + { + Name = name, + ModificationType = modificationType + }; + } + + private static ModificationVersion CreateVersion(bool installed) + { + return new ModificationVersion + { + Version = "1.0", + Installed = installed + }; + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherTileActionServiceTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherTileActionServiceTests.cs new file mode 100644 index 00000000..7d0ad92f --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherTileActionServiceTests.cs @@ -0,0 +1,327 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.UI.Features.Launcher.Models; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Mods; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Services; + +public sealed class LauncherTileActionServiceTests +{ + [Fact] + public void GetAdvertisingDownloadActionReturnsLinkAndThankYouForAdvertisingWithSimpleLink() + { + // Arrange + GameModification modification = new() + { + ModificationType = ModificationType.Advertising, + SimpleDownloadLink = "https://example.test/donate" + }; + + // Act + LauncherTileLinkAction result = LauncherTileActionService.GetAdvertisingDownloadAction(modification); + + // Assert + result.Uri.Should().Be("https://example.test/donate"); + result.ShowThankYouMessage.Should().BeTrue(); + } + + [Fact] + public void GetAdvertisingDownloadActionReturnsNoActionForNonAdvertisingMod() + { + // Arrange + GameModification modification = new() + { + ModificationType = ModificationType.Mod, + SimpleDownloadLink = "https://example.test/download" + }; + + // Act + LauncherTileLinkAction result = LauncherTileActionService.GetAdvertisingDownloadAction(modification); + + // Assert + result.Uri.Should().BeNull(); + result.ShowThankYouMessage.Should().BeFalse(); + } + + [Fact] + public void GetNewsActionShowsThankYouOnlyForAdvertising() + { + // Arrange + GameModification modification = new() + { + ModificationType = ModificationType.Advertising, + NewsLink = "https://example.test/news" + }; + + // Act + LauncherTileLinkAction result = LauncherTileActionService.GetNewsAction(modification); + + // Assert + result.Uri.Should().Be("https://example.test/news"); + result.ShowThankYouMessage.Should().BeTrue(); + } + + [Fact] + public void GetNetworkInfoActionReturnsNetworkLink() + { + // Arrange + GameModification modification = new() + { + ModificationType = ModificationType.Mod, + NetworkInfo = "https://example.test/network" + }; + + // Act + LauncherTileLinkAction result = LauncherTileActionService.GetNetworkInfoAction(modification); + + // Assert + result.Uri.Should().Be("https://example.test/network"); + result.ShowThankYouMessage.Should().BeFalse(); + } + + [Fact] + public void GetSupportActionAlwaysShowsThankYou() + { + // Arrange + GameModification modification = new() + { + SupportLink = "https://example.test/support" + }; + + // Act + LauncherTileLinkAction result = LauncherTileActionService.GetSupportAction(modification); + + // Assert + result.Uri.Should().Be("https://example.test/support"); + result.ShowThankYouMessage.Should().BeTrue(); + } + + [Fact] + public void GetContextMenuStateHidesMissingLinksAndNonManualSetImage() + { + // Arrange + GameModification modification = new() + { + ModificationType = ModificationType.Mod, + ModDBLink = string.Empty, + DiscordLink = string.Empty + }; + + // Act + LauncherContextMenuState result = LauncherTileActionService.GetContextMenuState( + modification, + latestVersionIsManual: false); + + // Assert + result.HiddenResourceKeys.Should().BeEquivalentTo( + LauncherTileActionService.ModDbResourceKey, + LauncherTileActionService.DiscordResourceKey, + LauncherTileActionService.SetImageResourceKey); + } + + [Fact] + public void GetContextMenuStateHidesImageForAdvertising() + { + // Arrange + GameModification modification = new() + { + ModificationType = ModificationType.Advertising, + ModDBLink = "https://example.test/moddb", + DiscordLink = "https://example.test/discord" + }; + + // Act + LauncherContextMenuState result = LauncherTileActionService.GetContextMenuState( + modification, + latestVersionIsManual: true); + + // Assert + result.HiddenResourceKeys.Should().BeEquivalentTo( + LauncherTileActionService.SetImageResourceKey); + } + + [Fact] + public void DeleteVersionForModRemovesContentRefreshesLocalCatalogAndSaves() + { + // Arrange + ILauncherContentCatalogCommands catalogCommands = Substitute.For(); + LauncherTileActionService service = new(catalogCommands); + ModificationVersionSelection versionSelection = new() + { + VersionName = "2.0", + SelectedVersion = new ModificationVersion + { + Name = "Shockwave", + Version = "1.0", + ModificationType = ModificationType.Mod, + DependenceName = "Zero Hour" + } + }; + + // Act + bool removedContentCard = service.DeleteVersion(versionSelection); + + // Assert + removedContentCard.Should().BeTrue(); + catalogCommands.Received(1).RemoveContent(Arg.Is(version => + version.Name == "Shockwave" && + version.Version == "2.0" && + version.ModificationType == ModificationType.Mod && + version.DependenceName == "Zero Hour")); + catalogCommands.DidNotReceive().DeleteVersion(Arg.Any()); + catalogCommands.Received(1).UpdateLocalModificationsData(); + catalogCommands.Received(1).SaveLauncherData(); + } + + [Fact] + public void DeleteVersionForRemoteChildContentDeletesSelectedVersionAndRefreshesLocalCatalog() + { + // Arrange + ILauncherContentCatalogCommands catalogCommands = Substitute.For(); + LauncherTileActionService service = new(catalogCommands); + ModificationVersionSelection versionSelection = new() + { + VersionName = "1.1", + SelectedVersion = new ModificationVersion + { + Name = "HD", + Version = "1.0", + ModificationType = ModificationType.Addon, + DependenceName = "Shockwave", + ContentSourceKind = ContentSourceKind.ManagedSingleFile + } + }; + + // Act + bool removedContentCard = service.DeleteVersion(versionSelection); + + // Assert + removedContentCard.Should().BeFalse(); + catalogCommands.Received(1).DeleteVersion(Arg.Is(version => + version.Name == "HD" && + version.Version == "1.1" && + version.ModificationType == ModificationType.Addon && + version.DependenceName == "Shockwave")); + catalogCommands.DidNotReceive().RemoveContent(Arg.Any()); + catalogCommands.Received(1).UpdateLocalModificationsData(); + catalogCommands.DidNotReceive().SaveLauncherData(); + } + + [Theory] + [InlineData(ModificationType.Addon)] + [InlineData(ModificationType.Patch)] + public void DeleteVersionForManualChildContentRemovesContentRefreshesLocalCatalogAndSaves( + ModificationType modificationType) + { + // Arrange + ILauncherContentCatalogCommands catalogCommands = Substitute.For(); + LauncherTileActionService service = new(catalogCommands); + ModificationVersion selectedVersion = new() + { + Name = "Manual Content", + Version = "1.0", + ModificationType = modificationType, + DependenceName = "Shockwave", + ContentSourceKind = ContentSourceKind.Manual + }; + ModificationVersionSelection versionSelection = new() + { + VersionName = "1.0", + SelectedVersion = selectedVersion, + ModificationViewModel = CreateViewModel(selectedVersion, "Shockwave") + }; + + // Act + bool removedContentCard = service.DeleteVersion(versionSelection); + + // Assert + removedContentCard.Should().BeTrue(); + catalogCommands.Received(1).RemoveContent(Arg.Is(version => + version.Name == "Manual Content" && + version.Version == "1.0" && + version.ModificationType == modificationType && + version.DependenceName == "Shockwave" && + version.ContentSourceKind == ContentSourceKind.Manual)); + catalogCommands.DidNotReceive().DeleteVersion(Arg.Any()); + catalogCommands.Received(1).UpdateLocalModificationsData(); + catalogCommands.Received(1).SaveLauncherData(); + } + + [Fact] + public void RemoveContentVersionDeletesLatestVersionRemovesCatalogEntryAndSaves() + { + // Arrange + ILauncherContentCatalogCommands catalogCommands = Substitute.For(); + LauncherTileActionService service = new(catalogCommands); + ModificationVersion latestVersion = new() + { + Name = "Shockwave", + Version = "2.0", + ModificationType = ModificationType.Mod, + DependenceName = "Zero Hour" + }; + ModificationViewModel viewModel = CreateViewModel(latestVersion); + + // Act + service.RemoveContentVersion(viewModel); + + // Assert + catalogCommands.Received(1).RemoveContentVersion(Arg.Is(version => + version.Name == "Shockwave" && + version.Version == "2.0" && + version.ModificationType == ModificationType.Mod && + version.DependenceName == "Zero Hour")); + catalogCommands.Received(1).UpdateLocalModificationsData(); + catalogCommands.Received(1).SaveLauncherData(); + } + + [Fact] + public void DeleteLocalContentVersionDeletesLatestVersionKeepsCatalogEntryAndSaves() + { + // Arrange + ILauncherContentCatalogCommands catalogCommands = Substitute.For(); + LauncherTileActionService service = new(catalogCommands); + ModificationVersion latestVersion = new() + { + Name = "HD", + Version = "1.0", + ModificationType = ModificationType.Addon + }; + ModificationViewModel viewModel = CreateViewModel(latestVersion, "Shockwave"); + + // Act + service.DeleteLocalContentVersion(viewModel); + + // Assert + catalogCommands.Received(1).DeleteModificationVersion(Arg.Is(version => + version.Name == "HD" && + version.Version == "1.0" && + version.ModificationType == ModificationType.Addon && + version.DependenceName == "Shockwave")); + catalogCommands.DidNotReceive().RemoveContentVersion(Arg.Any()); + catalogCommands.Received(1).UpdateLocalModificationsData(); + catalogCommands.Received(1).SaveLauncherData(); + } + + private static ModificationViewModel CreateViewModel( + ModificationVersion version, + string? containerDependenceName = null) + { + return new ModificationViewModel( + new GameModification + { + Name = version.Name, + ModificationType = version.ModificationType, + DependenceName = containerDependenceName ?? version.DependenceName, + ModificationVersions = new List { version } + }, + new ModificationImageSourceFactory(Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance), + new GenLauncherGO.Tests.Testing.TestLauncherModsContext(), + Substitute.For(), + new GenLauncherGO.Tests.Testing.TestStringLocalizer(), + Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherTileVersionSelectionServiceTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherTileVersionSelectionServiceTests.cs new file mode 100644 index 00000000..64215516 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherTileVersionSelectionServiceTests.cs @@ -0,0 +1,77 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Mods; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Services; + +public sealed class LauncherTileVersionSelectionServiceTests +{ + [Fact] + public void SelectVersionSelectsMatchingVersionAndClearsOthers() + { + // Arrange + LauncherTileVersionSelectionService service = new(); + ModificationVersion oldVersion = CreateVersion("1.0", selected: true); + ModificationVersion selectedVersion = CreateVersion("2.0", selected: false); + ModificationVersion otherVersion = CreateVersion("3.0", selected: true); + ModificationViewModel viewModel = CreateViewModel(oldVersion, selectedVersion, otherVersion); + ModificationVersionSelection versionSelection = new(selectedVersion, "2.0", viewModel); + + // Act + service.SelectVersion(versionSelection); + + // Assert + oldVersion.IsSelected.Should().BeFalse(); + selectedVersion.IsSelected.Should().BeTrue(); + otherVersion.IsSelected.Should().BeFalse(); + } + + [Fact] + public void SelectVersionMatchesVersionCaseInsensitively() + { + // Arrange + LauncherTileVersionSelectionService service = new(); + ModificationVersion selectedVersion = CreateVersion("Release", selected: false); + ModificationViewModel viewModel = CreateViewModel(selectedVersion); + ModificationVersionSelection versionSelection = new(selectedVersion, "release", viewModel); + + // Act + service.SelectVersion(versionSelection); + + // Assert + selectedVersion.IsSelected.Should().BeTrue(); + } + + private static ModificationViewModel CreateViewModel(params ModificationVersion[] versions) + { + GameModification modification = new() + { + Name = "Test Mod", + ModificationType = ModificationType.Mod, + ModificationVersions = new List(versions) + }; + + return new ModificationViewModel( + modification, + new ModificationImageSourceFactory(NullLogger.Instance), + new TestLauncherModsContext(), + Substitute.For(), + new TestStringLocalizer(), + NullLogger.Instance); + } + + private static ModificationVersion CreateVersion(string version, bool selected) + { + return new ModificationVersion + { + Name = "Test Mod", + Version = version, + ModificationType = ModificationType.Mod, + IsSelected = selected + }; + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherVisualThemeServiceTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherVisualThemeServiceTests.cs new file mode 100644 index 00000000..f9462228 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherVisualThemeServiceTests.cs @@ -0,0 +1,240 @@ +using System.Collections.Generic; +using System.IO; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Mods; +using GenLauncherGO.UI.Features.Startup; +using GenLauncherGO.UI.Shared.Themes; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Services; + +public sealed class LauncherVisualThemeServiceTests +{ + [Fact] + public void SetDefaultVisualRestoresRuntimeDefaultColors() + { + // Arrange + ColorsInfo defaultColors = new(); + LauncherRuntimeContext runtimeContext = CreateRuntimeContext(defaultColors); + runtimeContext.Colors = new ColorsInfo(); + LauncherVisualThemeService service = CreateService(runtimeContext); + + // Act + service.SetDefaultVisual(); + + // Assert + runtimeContext.Colors.Should().BeSameAs(defaultColors); + } + + [Fact] + public void UpdateVisualResourcesForModUsesCachedContainerColors() + { + // Arrange + ColorsInfo selectedColors = new(); + LauncherRuntimeContext runtimeContext = CreateRuntimeContext(new ColorsInfo()); + ModificationViewModel viewModel = CreateViewModel(CreateModification()); + viewModel.Colors = selectedColors; + LauncherVisualThemeService service = CreateService(runtimeContext); + + // Act + service.UpdateVisualResourcesForMod(viewModel); + + // Assert + runtimeContext.Colors.Should().BeSameAs(selectedColors); + } + + [Fact] + public void UpdateVisualResourcesForModRestoresDefaultsWhenModificationHasNoColorInfo() + { + // Arrange + ColorsInfo defaultColors = new(); + LauncherRuntimeContext runtimeContext = CreateRuntimeContext(defaultColors); + runtimeContext.Colors = new ColorsInfo(); + ModificationViewModel viewModel = CreateViewModel(CreateModification()); + LauncherVisualThemeService service = CreateService(runtimeContext); + + // Act + service.UpdateVisualResourcesForMod(viewModel); + + // Assert + runtimeContext.Colors.Should().BeSameAs(defaultColors); + } + + [Fact] + public void UpdateVisualResourcesForModLeavesCurrentColorsWhenBackgroundImageIsMissing() + { + // Arrange + ColorsInfo currentColors = new(); + LauncherRuntimeContext runtimeContext = CreateRuntimeContext(new ColorsInfo()); + runtimeContext.Colors = currentColors; + IModificationImageFileService imageFileService = Substitute.For(); + imageFileService.FindExistingImageFilePath("Test Mod", "1.0-background") + .Returns(@"C:\Images\Test Mod\1.0-background.png"); + imageFileService.ImageExists(Arg.Any()).Returns(false); + ModificationViewModel viewModel = CreateViewModel(CreateModification(CreateColorInfoString())); + LauncherVisualThemeService service = CreateService(runtimeContext, imageFileService); + + // Act + service.UpdateVisualResourcesForMod(viewModel); + + // Assert + runtimeContext.Colors.Should().BeSameAs(currentColors); + viewModel.Colors.Should().BeNull(); + } + + [Fact] + public void UpdateVisualResourcesForModLoadsBackgroundImageWhenCachedImageExists() + { + StaTestRunner.Run(() => + { + // Arrange + using TestDirectory testDirectory = new(); + string imagePath = Path.Combine(testDirectory.Path, "background.png"); + SaveTestImage(imagePath); + LauncherRuntimeContext runtimeContext = CreateRuntimeContext(new ColorsInfo()); + IModificationImageFileService imageFileService = Substitute.For(); + imageFileService.FindExistingImageFilePath("Test Mod", "1.0-background").Returns(imagePath); + imageFileService.ImageExists(imagePath).Returns(true); + ModificationViewModel viewModel = CreateViewModel(CreateModification(CreateColorInfoString())); + LauncherVisualThemeService service = CreateService(runtimeContext, imageFileService); + + // Act + service.UpdateVisualResourcesForMod(viewModel); + + // Assert + viewModel.Colors.Should().NotBeNull(); + runtimeContext.Colors.Should().BeSameAs(viewModel.Colors); + viewModel.Colors!.GenLauncherBackgroundImage.Should().NotBeNull(); + viewModel.Colors.GenLauncherBackgroundImage!.ImageSource.Should().NotBeNull(); + }); + } + + [Fact] + public void UpdateVisualResourcesForModRemovesInvalidCachedBackgroundImage() + { + StaTestRunner.Run(() => + { + // Arrange + using TestDirectory testDirectory = new(); + string imagePath = Path.Combine(testDirectory.Path, "background.png"); + File.WriteAllText(imagePath, "not an image"); + LauncherRuntimeContext runtimeContext = CreateRuntimeContext(new ColorsInfo()); + IModificationImageFileService imageFileService = Substitute.For(); + imageFileService.FindExistingImageFilePath("Test Mod", "1.0-background").Returns(imagePath); + imageFileService.ImageExists(imagePath).Returns(true); + ModificationViewModel viewModel = CreateViewModel(CreateModification(CreateColorInfoString())); + LauncherVisualThemeService service = CreateService(runtimeContext, imageFileService); + + // Act + service.UpdateVisualResourcesForMod(viewModel); + + // Assert + imageFileService.Received(1).TryDeleteImage(imagePath); + runtimeContext.Colors.Should().BeSameAs(viewModel.Colors); + }); + } + + private static LauncherVisualThemeService CreateService( + LauncherRuntimeContext runtimeContext, + IModificationImageFileService? imageFileService = null) + { + return new LauncherVisualThemeService( + runtimeContext, + imageFileService ?? Substitute.For(), + NullLogger.Instance); + } + + private static LauncherRuntimeContext CreateRuntimeContext(ColorsInfo defaultColors) + { + return new LauncherRuntimeContext(CreatePaths(), "1.0") + { + DefaultColors = defaultColors + }; + } + + private static ModificationViewModel CreateViewModel(GameModification modification) + { + return new ModificationViewModel( + modification, + new ModificationImageSourceFactory(NullLogger.Instance), + new TestLauncherModsContext(), + Substitute.For(), + new TestStringLocalizer(), + NullLogger.Instance); + } + + private static GameModification CreateModification(ColorsInfoString? colorsInformation = null) + { + return new GameModification + { + Name = "Test Mod", + ModificationType = ModificationType.Mod, + ColorsInformation = colorsInformation, + ModificationVersions = new List + { + new ModificationVersion + { + Name = "Test Mod", + Version = "1.0", + ModificationType = ModificationType.Mod + } + } + }; + } + + private static ColorsInfoString CreateColorInfoString() + { + return new ColorsInfoString( + "#00e3ff", + "DarkGray", + "#7a7db0", + "#baff0c", + "#232977", + "#090502", + "#B3000000", + "White", + "#090502", + "#F21d2057", + "#F21d2057", + "#2534ff"); + } + + private static void SaveTestImage(string path) + { + DrawingVisual visual = new(); + using (DrawingContext drawingContext = visual.RenderOpen()) + { + drawingContext.DrawRectangle(Brushes.DarkRed, null, new Rect(0, 0, 2, 2)); + } + + RenderTargetBitmap bitmap = new(2, 2, 96, 96, PixelFormats.Pbgra32); + bitmap.Render(visual); + + PngBitmapEncoder encoder = new(); + encoder.Frames.Add(BitmapFrame.Create(bitmap)); + + using FileStream stream = File.Create(path); + encoder.Save(stream); + } + + private static LauncherPaths CreatePaths() + { + return new LauncherPaths( + "C:\\Game", + "C:\\Game\\GenLauncherGO", + "C:\\Game\\GenLauncherGO\\Runtime", + "C:\\Game\\GenLauncherGO\\Runtime\\Cache", + "C:\\Game\\GenLauncherGO\\Runtime\\Cache\\Images", + "C:\\Game\\GenLauncherGO\\Mods", + "C:\\Game\\GenLauncherGO\\Logs", + "C:\\Game\\GenLauncherGO\\Runtime\\Temp", + "C:\\Game\\GenLauncherGO\\Runtime\\Deployment"); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherWindowWorkflowCoordinatorTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherWindowWorkflowCoordinatorTests.cs new file mode 100644 index 00000000..588cb5d3 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Services/LauncherWindowWorkflowCoordinatorTests.cs @@ -0,0 +1,1281 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Settings.Contracts; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Shell.Contracts; +using GenLauncherGO.Core.Shell.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Models; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Launcher.Contracts; +using GenLauncherGO.UI.Features.Launcher.Models; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Launcher.ViewModels; +using GenLauncherGO.UI.Features.Mods; +using GenLauncherGO.UI.Features.Mods.Contracts; +using GenLauncherGO.UI.Features.Settings.Contracts; +using GenLauncherGO.UI.Features.Startup; +using GenLauncherGO.UI.Shared.Themes; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Services; + +public sealed class LauncherWindowWorkflowCoordinatorTests +{ + public static IEnumerable LinkActions + { + get + { + yield return new object[] + { + MainWindowModificationActionKind.ChangeLog, + "https://example.test/news", + new Action(modification => modification.NewsLink = "https://example.test/news"), + }; + yield return new object[] + { + MainWindowModificationActionKind.NetworkInfo, + "https://example.test/network", + new Action(modification => modification.NetworkInfo = "https://example.test/network"), + }; + yield return new object[] + { + MainWindowModificationActionKind.ModDb, + "https://example.test/moddb", + new Action(modification => modification.ModDBLink = "https://example.test/moddb"), + }; + yield return new object[] + { + MainWindowModificationActionKind.Discord, + "https://example.test/discord", + new Action(modification => modification.DiscordLink = "https://example.test/discord"), + }; + } + } + + [Fact] + public void GetHiddenContextMenuHeadersLocalizesHiddenLinkAndImageActions() + { + StaTestRunner.Run(() => + { + // Arrange + LauncherWindowWorkflowCoordinator coordinator = CreateCoordinator(); + ModificationViewModel viewModel = CreateTile( + CreateModification( + "Shockwave", + ModificationType.Mod, + CreateVersion("Shockwave", ContentSourceKind.ManagedSingleFile))); + + // Act + IReadOnlyList hiddenHeaders = coordinator.GetHiddenContextMenuHeaders(viewModel); + + // Assert + hiddenHeaders.Should().BeEquivalentTo("Mod DB", "Discord", "Set image"); + }); + } + + [Theory] + [MemberData(nameof(LinkActions))] + public void ApplyModificationActionAsyncForLinkActionsOpensConfiguredUri( + object kindValue, + string expectedUri, + Action configureModification) + { + StaTestRunner.Run(async () => + { + // Arrange + LauncherWindowWorkflowCoordinator coordinator = CreateCoordinator( + out ILauncherShellService shellService); + GameModification modification = CreateModification( + "Shockwave", + ModificationType.Mod, + CreateVersion("Shockwave", ContentSourceKind.Manual)); + configureModification(modification); + ModificationViewModel viewModel = CreateTile(modification); + + // Act + await coordinator.ApplyModificationActionAsync( + new StubWorkflowView(), + new MainWindowModificationActionRequestedEventArgs( + (MainWindowModificationActionKind)kindValue, + viewModel), + CancellationToken.None); + + // Assert + shellService.Received(1).OpenUri(expectedUri); + }); + } + + [Fact] + public void ApplyModificationActionAsyncForSupportShowsThankYouAndOpensSupportLink() + { + StaTestRunner.Run(async () => + { + // Arrange + LauncherWindowWorkflowCoordinator coordinator = CreateCoordinator( + out ILauncherShellService shellService); + ModificationViewModel viewModel = CreateTile(CreateModification( + "Shockwave", + ModificationType.Mod, + CreateVersion("Shockwave", ContentSourceKind.Manual), + supportLink: "https://example.test/support")); + + // Act + await coordinator.ApplyModificationActionAsync( + new StubWorkflowView(), + new MainWindowModificationActionRequestedEventArgs( + MainWindowModificationActionKind.Support, + viewModel), + CancellationToken.None); + + // Assert + viewModel.ProgressMessage.Should().Be("Thank you"); + shellService.Received(1).OpenUri("https://example.test/support"); + }); + } + + [Fact] + public void ApplyModificationActionAsyncForAdvertisingUpdateOpensAdvertisingLink() + { + StaTestRunner.Run(async () => + { + // Arrange + LauncherWindowWorkflowCoordinator coordinator = CreateCoordinator( + out ILauncherShellService shellService); + ModificationViewModel viewModel = CreateTile(CreateModification( + "Donate", + ModificationType.Advertising, + CreateVersion("Donate", ContentSourceKind.UnknownLegacy), + simpleDownloadLink: "https://example.test/donate")); + StubWorkflowView view = new(); + + // Act + await coordinator.ApplyModificationActionAsync( + view, + new MainWindowModificationActionRequestedEventArgs( + MainWindowModificationActionKind.Update, + viewModel), + CancellationToken.None); + + // Assert + viewModel.ProgressMessage.Should().Be("Thank you"); + shellService.Received(1).OpenUri("https://example.test/donate"); + view.EnsureContentSelectedCount.Should().Be(0); + }); + } + + [Fact] + public void ApplyModificationActionAsyncForManualImageReplacementReplacesImageAndRefreshesTile() + { + StaTestRunner.Run(async () => + { + // Arrange + IModificationImageFileService imageFileService = Substitute.For(); + imageFileService.ReplaceImageAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(@"C:\Cache\Shockwave\1.0.png")); + LauncherWindowWorkflowCoordinator coordinator = CreateCoordinator( + filePicker: new StubLauncherFilePicker(pickedImageFile: @"C:\Pictures\custom.png"), + modificationImageFileService: imageFileService); + ModificationViewModel viewModel = CreateTile(CreateModification( + "Shockwave", + ModificationType.Mod, + CreateVersion("Shockwave", ContentSourceKind.Manual))); + StubWorkflowView view = new(); + + // Act + await coordinator.ApplyModificationActionAsync( + view, + new MainWindowModificationActionRequestedEventArgs( + MainWindowModificationActionKind.ChangeVersionImage, + viewModel), + CancellationToken.None); + + // Assert + view.DisableUiCount.Should().Be(1); + view.EnableUiCount.Should().Be(1); + await imageFileService.Received(1).ReplaceImageAsync( + Arg.Is(request => + request.ModificationName == "Shockwave" && + request.ImageBaseName == "1.0" && + request.SourceImagePath == @"C:\Pictures\custom.png"), + Arg.Any()); + }); + } + + [Fact] + public void AddRepositoryModificationAsyncWhenSelectionIsReturnedAddsModificationAndRestoresFocus() + { + StaTestRunner.Run(async () => + { + // Arrange + ILauncherDialogService dialogService = Substitute.For(); + dialogService.ShowModificationSelection( + Arg.Any>(), + Arg.Any()) + .Returns("Shockwave"); + LauncherWindowWorkflowCoordinator coordinator = CreateCoordinator(dialogService: dialogService); + MainWindowViewModel viewModel = CreateMainWindowViewModel(); + StubWorkflowView view = new(); + + // Act + await coordinator.AddRepositoryModificationAsync(viewModel, view); + + // Assert + view.AddRepositoryModificationNames.Should().Equal("Shockwave"); + view.RestoreFocusesCount.Should().Be(1); + }); + } + + [Fact] + public void AddRepositoryModificationAsyncWhenPackageActivityIsActiveShowsInfoAndRestoresFocus() + { + StaTestRunner.Run(async () => + { + // Arrange + LauncherPackageActivityService packageActivityService = new(); + packageActivityService.TryBegin( + "Download", + out LauncherPackageActivityService.LauncherPackageActivityLease? lease) + .Should() + .BeTrue(); + ILauncherDialogService dialogService = Substitute.For(); + LauncherWindowWorkflowCoordinator coordinator = CreateCoordinator( + packageActivityService: packageActivityService, + dialogService: dialogService); + MainWindowViewModel viewModel = CreateMainWindowViewModel(); + StubWorkflowView view = new(); + + try + { + // Act + await coordinator.AddRepositoryModificationAsync(viewModel, view); + + // Assert + view.RestoreFocusesCount.Should().Be(1); + view.AddRepositoryModificationNames.Should().BeEmpty(); + dialogService.Received(1).ShowInfo( + Arg.Is(request => + request.MainMessage == "Package activity" && + request.DetailMessage == "Package activity details"), + view.OwnerWindow); + } + finally + { + lease?.Dispose(); + } + }); + } + + [Fact] + public void ImportManualContentAsyncWhenImportSucceedsDisablesUiAddsResultAndEnablesUi() + { + StaTestRunner.Run(async () => + { + // Arrange + LauncherManualImportResult importResult = new( + LauncherManualImportKind.Modification, + CreateModification("Manual Mod", ModificationType.Mod, CreateVersion("Manual Mod", ContentSourceKind.Manual))); + LauncherWindowWorkflowCoordinator coordinator = CreateCoordinator( + manualImportResult: importResult); + MainWindowViewModel viewModel = CreateMainWindowViewModel(); + StubWorkflowView view = new(); + + // Act + await coordinator.ImportManualContentAsync( + viewModel, + view, + LauncherManualImportKind.Modification, + CancellationToken.None); + + // Assert + view.DisableUiCount.Should().Be(1); + view.EnableUiCount.Should().Be(1); + view.ImportedResults.Should().ContainSingle(); + LauncherManualImportResult addedResult = view.ImportedResults[0]; + addedResult.Kind.Should().Be(importResult.Kind); + addedResult.Modification.Should().BeSameAs(importResult.Modification); + }); + } + + [Fact] + public void AddRepositoryModificationAsyncWhenSelectionIsCanceledRestoresFocusWithoutAdding() + { + StaTestRunner.Run(async () => + { + // Arrange + ILauncherDialogService dialogService = Substitute.For(); + dialogService.ShowModificationSelection( + Arg.Any>(), + Arg.Any()) + .Returns((string?)null); + LauncherWindowWorkflowCoordinator coordinator = CreateCoordinator(dialogService: dialogService); + MainWindowViewModel viewModel = CreateMainWindowViewModel(); + StubWorkflowView view = new(); + + // Act + await coordinator.AddRepositoryModificationAsync(viewModel, view); + + // Assert + view.AddRepositoryModificationNames.Should().BeEmpty(); + view.RestoreFocusesCount.Should().Be(1); + }); + } + + [Fact] + public void ImportManualContentAsyncWhenPackageActivityIsActiveShowsInfoWithoutChangingUi() + { + StaTestRunner.Run(async () => + { + // Arrange + LauncherPackageActivityService packageActivityService = new(); + packageActivityService.TryBegin( + "Download", + out LauncherPackageActivityService.LauncherPackageActivityLease? lease) + .Should() + .BeTrue(); + ILauncherDialogService dialogService = Substitute.For(); + LauncherWindowWorkflowCoordinator coordinator = CreateCoordinator( + packageActivityService: packageActivityService, + dialogService: dialogService); + MainWindowViewModel viewModel = CreateMainWindowViewModel(); + StubWorkflowView view = new(); + + try + { + // Act + await coordinator.ImportManualContentAsync( + viewModel, + view, + LauncherManualImportKind.Modification, + CancellationToken.None); + + // Assert + view.DisableUiCount.Should().Be(0); + view.EnableUiCount.Should().Be(0); + view.ImportedResults.Should().BeEmpty(); + dialogService.Received(1).ShowInfo( + Arg.Is(request => + request.MainMessage == "Package activity" && + request.DetailMessage == "Package activity details"), + view.OwnerWindow); + } + finally + { + lease?.Dispose(); + } + }); + } + + [Fact] + public void ConfirmCloseDuringActivePackageActivity_WhenNoActivityIsActive_AllowsCloseWithoutDialog() + { + StaTestRunner.Run(() => + { + // Arrange + ILauncherDialogService dialogService = Substitute.For(); + LauncherWindowWorkflowCoordinator coordinator = CreateCoordinator(dialogService: dialogService); + + // Act + bool canClose = coordinator.ConfirmCloseDuringActivePackageActivity(new Window()); + + // Assert + canClose.Should().BeTrue(); + dialogService.DidNotReceive().ShowWarningConfirmation( + Arg.Any(), + Arg.Any(), + Arg.Any()); + }); + } + + [Fact] + public void ConfirmCloseDuringActivePackageActivity_WhenActivityIsActive_UsesWarningConfirmation() + { + StaTestRunner.Run(() => + { + // Arrange + LauncherPackageActivityService packageActivityService = new(); + packageActivityService.TryBegin( + "Shockwave", + out LauncherPackageActivityService.LauncherPackageActivityLease? lease) + .Should() + .BeTrue(); + ILauncherDialogService dialogService = Substitute.For(); + dialogService.ShowWarningConfirmation( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(false); + LauncherWindowWorkflowCoordinator coordinator = CreateCoordinator( + packageActivityService: packageActivityService, + dialogService: dialogService); + var owner = new Window(); + + try + { + // Act + bool canClose = coordinator.ConfirmCloseDuringActivePackageActivity(owner); + + // Assert + canClose.Should().BeFalse(); + dialogService.Received(1).ShowWarningConfirmation( + Arg.Is(request => + request.MainMessage == "Package activity" && + request.DetailMessage == "Close Shockwave?"), + "Close anyway", + owner); + } + finally + { + lease?.Dispose(); + } + }); + } + + [Fact] + public void ApplyModificationActionAsyncForNonManualImageReplacementDoesNothing() + { + StaTestRunner.Run(async () => + { + // Arrange + IModificationImageFileService imageFileService = Substitute.For(); + LauncherWindowWorkflowCoordinator coordinator = CreateCoordinator( + filePicker: new StubLauncherFilePicker(pickedImageFile: @"C:\Pictures\custom.png"), + modificationImageFileService: imageFileService); + ModificationViewModel viewModel = CreateTile(CreateModification( + "Shockwave", + ModificationType.Mod, + CreateVersion("Shockwave", ContentSourceKind.ManagedSingleFile))); + StubWorkflowView view = new(); + + // Act + await coordinator.ApplyModificationActionAsync( + view, + new MainWindowModificationActionRequestedEventArgs( + MainWindowModificationActionKind.ChangeVersionImage, + viewModel), + CancellationToken.None); + + // Assert + view.DisableUiCount.Should().Be(0); + view.EnableUiCount.Should().Be(0); + await imageFileService.DidNotReceive().ReplaceImageAsync( + Arg.Any(), + Arg.Any()); + }); + } + + [Fact] + public void ApplyModificationActionAsyncForUnknownActionThrows() + { + StaTestRunner.Run(async () => + { + // Arrange + LauncherWindowWorkflowCoordinator coordinator = CreateCoordinator(); + ModificationViewModel viewModel = CreateTile(CreateModification( + "Shockwave", + ModificationType.Mod, + CreateVersion("Shockwave", ContentSourceKind.Manual))); + + // Act + Func act = () => coordinator.ApplyModificationActionAsync( + new StubWorkflowView(), + new MainWindowModificationActionRequestedEventArgs( + (MainWindowModificationActionKind)999, + viewModel), + CancellationToken.None); + + // Assert + await act.Should().ThrowAsync(); + }); + } + + [Fact] + public void DeleteVersionForModRemovesContentCardAndRefreshesTabs() + { + StaTestRunner.Run(() => + { + // Arrange + ILauncherContentCatalogCommands catalogCommands = Substitute.For(); + LauncherWindowWorkflowCoordinator coordinator = CreateCoordinator(catalogCommands: catalogCommands); + ModificationViewModel viewModel = CreateTile(CreateModification( + "Shockwave", + ModificationType.Mod, + CreateVersion("Shockwave", ContentSourceKind.Manual))); + ModificationVersionSelection versionSelection = new( + viewModel.LatestVersion, + viewModel.LatestVersion.Version, + viewModel); + StubWorkflowView view = new(); + + // Act + coordinator.DeleteVersion(view, versionSelection); + + // Assert + catalogCommands.Received(1).RemoveContent(Arg.Is(version => + version.Name == "Shockwave" && + version.Version == "1.0" && + version.ModificationType == ModificationType.Mod)); + catalogCommands.Received(1).UpdateLocalModificationsData(); + catalogCommands.Received(1).SaveLauncherData(); + view.RemoveContentFromListCount.Should().Be(1); + view.RefreshTabsCount.Should().Be(1); + view.RefreshAddonAndPatchTabLabelsCount.Should().Be(1); + }); + } + + [Fact] + public void DeleteVersionForRemoteChildContentRefreshesTileAndLabels() + { + StaTestRunner.Run(() => + { + // Arrange + ILauncherContentCatalogCommands catalogCommands = Substitute.For(); + LauncherWindowWorkflowCoordinator coordinator = CreateCoordinator(catalogCommands: catalogCommands); + ModificationViewModel viewModel = CreateTile(CreateModification( + "HD", + ModificationType.Addon, + CreateVersion("HD", ContentSourceKind.ManagedSingleFile))); + ModificationVersionSelection versionSelection = new( + viewModel.LatestVersion, + viewModel.LatestVersion.Version, + viewModel); + StubWorkflowView view = new(); + + // Act + coordinator.DeleteVersion(view, versionSelection); + + // Assert + catalogCommands.Received(1).DeleteVersion(Arg.Is(version => + version.Name == "HD" && + version.Version == "1.0" && + version.ModificationType == ModificationType.Addon)); + catalogCommands.Received(1).UpdateLocalModificationsData(); + catalogCommands.DidNotReceive().SaveLauncherData(); + view.RemoveContentFromListCount.Should().Be(0); + view.RefreshTabsCount.Should().Be(0); + view.RefreshAddonAndPatchTabLabelsCount.Should().Be(1); + }); + } + + [Theory] + [InlineData(ModificationType.Addon)] + [InlineData(ModificationType.Patch)] + public void DeleteVersionForManualChildContentRemovesContentCardAndRefreshesTabs( + ModificationType modificationType) + { + StaTestRunner.Run(() => + { + // Arrange + ILauncherContentCatalogCommands catalogCommands = Substitute.For(); + LauncherWindowWorkflowCoordinator coordinator = CreateCoordinator(catalogCommands: catalogCommands); + ModificationViewModel viewModel = CreateTile(CreateModification( + "Manual Child", + modificationType, + CreateVersion("Manual Child", ContentSourceKind.Manual))); + ModificationVersionSelection versionSelection = new( + viewModel.LatestVersion, + viewModel.LatestVersion.Version, + viewModel); + StubWorkflowView view = new(); + + // Act + coordinator.DeleteVersion(view, versionSelection); + + // Assert + catalogCommands.Received(1).RemoveContent(Arg.Is(version => + version.Name == "Manual Child" && + version.Version == "1.0" && + version.ModificationType == modificationType)); + catalogCommands.Received(1).UpdateLocalModificationsData(); + catalogCommands.Received(1).SaveLauncherData(); + view.RemoveContentFromListCount.Should().Be(1); + view.RefreshTabsCount.Should().Be(1); + view.RefreshAddonAndPatchTabLabelsCount.Should().Be(1); + }); + } + + [Fact] + public void LaunchGameAsyncWhenExecutableIsUnavailableShowsErrorAndDoesNotLaunch() + { + StaTestRunner.Run(async () => + { + // Arrange + IGameExecutableDiscoveryService executableDiscovery = Substitute.For(); + executableDiscovery.IsExecutableAvailable(Arg.Any()).Returns(false); + ILauncherDialogService dialogService = Substitute.For(); + LauncherWindowWorkflowCoordinator coordinator = CreateCoordinator( + dialogService: dialogService, + executableDiscovery: executableDiscovery); + MainWindowViewModel viewModel = CreateMainWindowViewModel(); + viewModel.SelectedGameClientOption = new GameClientOption( + "Generals", + "generals.exe", + GameClientExecutableKind.Community); + StubWorkflowView view = new(); + + // Act + await coordinator.LaunchGameAsync(viewModel, view, CancellationToken.None); + + // Assert + dialogService.Received(1).ShowError( + Arg.Is(request => + request.MainMessage == "Launch aborted" && + request.DetailMessage == "No client"), + view.OwnerWindow); + view.DisableUiCount.Should().Be(0); + view.RestoreFocusesCount.Should().Be(0); + }); + } + + [Fact] + public void ApplyModificationActionAsyncForActiveModDownloadCancelsAndRemovesPartialContent() + { + StaTestRunner.Run(async () => + { + // Arrange + ILauncherDialogService dialogService = Substitute.For(); + dialogService.ShowWarningConfirmation( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(true); + ILauncherContentCatalogCommands catalogCommands = Substitute.For(); + LauncherWindowWorkflowCoordinator coordinator = CreateCoordinator( + dialogService: dialogService, + catalogCommands: catalogCommands); + ModificationViewModel viewModel = CreateTile(CreateModification( + "Shockwave", + ModificationType.Mod, + CreateVersion("Shockwave", ContentSourceKind.ManagedSingleFile))); + var downloader = new CompletingPackageDownloadOperation(); + viewModel.SetDownloader(downloader); + StubWorkflowView view = new(); + + // Act + await coordinator.ApplyModificationActionAsync( + view, + new MainWindowModificationActionRequestedEventArgs( + MainWindowModificationActionKind.Update, + viewModel), + CancellationToken.None); + + // Assert + downloader.CancelDownloadCount.Should().Be(1); + catalogCommands.Received(1).RemoveContentVersion(Arg.Is(version => + version.Name == "Shockwave" && + version.Version == "1.0" && + version.ModificationType == ModificationType.Mod)); + catalogCommands.Received(1).UpdateLocalModificationsData(); + catalogCommands.Received(1).SaveLauncherData(); + view.RemoveContentFromListCount.Should().Be(1); + view.RefreshTabsCount.Should().Be(1); + view.RefreshAddonAndPatchTabLabelsCount.Should().Be(1); + view.RestoreFocusesCount.Should().Be(1); + }); + } + + private static LauncherWindowWorkflowCoordinator CreateCoordinator() + { + return CreateCoordinator(out _); + } + + private static LauncherWindowWorkflowCoordinator CreateCoordinator( + LauncherPackageActivityService? packageActivityService = null, + ILauncherDialogService? dialogService = null, + LauncherManualImportResult? manualImportResult = null, + ILauncherFilePicker? filePicker = null, + IModificationImageFileService? modificationImageFileService = null, + ILauncherContentCatalogCommands? catalogCommands = null, + IGameExecutableDiscoveryService? executableDiscovery = null) + { + return CreateCoordinator( + out _, + packageActivityService, + dialogService, + manualImportResult, + filePicker, + modificationImageFileService, + catalogCommands, + executableDiscovery); + } + + private static LauncherWindowWorkflowCoordinator CreateCoordinator( + out ILauncherShellService shellService, + LauncherPackageActivityService? packageActivityService = null, + ILauncherDialogService? dialogService = null, + LauncherManualImportResult? manualImportResult = null, + ILauncherFilePicker? filePicker = null, + IModificationImageFileService? modificationImageFileService = null, + ILauncherContentCatalogCommands? catalogCommands = null, + IGameExecutableDiscoveryService? executableDiscovery = null) + { + LauncherPackageActivityService resolvedPackageActivityService = packageActivityService ?? new(); + ILauncherDialogService resolvedDialogService = dialogService ?? Substitute.For(); + ILauncherContentCatalogCommands resolvedCatalogCommands = + catalogCommands ?? Substitute.For(); + ILauncherShellService resolvedShellService = Substitute.For(); + resolvedShellService.OpenUri(Arg.Any()).Returns(call => + ShellOpenResult.Success(call.ArgAt(0))); + shellService = resolvedShellService; + + return new LauncherWindowWorkflowCoordinator( + CreateLaunchCoordinator(resolvedPackageActivityService, resolvedDialogService), + CreateLaunchReadinessCoordinator(resolvedDialogService, executableDiscovery), + new LauncherTileActionService(resolvedCatalogCommands), + CreateVisualThemeService(), + CreateManualImportCoordinator(manualImportResult), + CreateDownloadCoordinator(resolvedPackageActivityService, resolvedDialogService), + resolvedPackageActivityService, + CreateShellNavigationCoordinator(resolvedShellService, resolvedDialogService), + filePicker ?? new StubLauncherFilePicker(), + CreateIntegrityCoordinator(resolvedPackageActivityService, resolvedDialogService), + CreateStringLocalizer(), + modificationImageFileService ?? Substitute.For(), + Substitute.For(), + resolvedDialogService); + } + + private static LauncherLaunchCoordinator CreateLaunchCoordinator( + LauncherPackageActivityService packageActivityService, + ILauncherDialogService dialogService) + { + ILaunchPreparationService preparationService = Substitute.For(); + preparationService.PrepareAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(LaunchPreparationResult.Success())); + preparationService.CleanupAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(LaunchPreparationResult.Success())); + IGameProcessLauncher processLauncher = Substitute.For(); + processLauncher.StartAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(CreateProcessOperation( + GameLaunchResult.Success("generals.exe", string.Empty, TimeSpan.Zero)))); + ILauncherPreferencesService preferencesService = Substitute.For(); + preferencesService.Current.Returns(new LauncherPreferences()); + + return new LauncherLaunchCoordinator( + preferencesService, + preparationService, + processLauncher, + CreateIntegrityCoordinator(packageActivityService, dialogService), + packageActivityService, + CreatePaths(), + new LauncherContentLayout("Addons", "Patches"), + new TestLauncherModsContext(), + CreateStringLocalizer(), + dialogService, + NullLogger.Instance); + } + + private static LauncherLaunchReadinessCoordinator CreateLaunchReadinessCoordinator( + ILauncherDialogService dialogService, + IGameExecutableDiscoveryService? executableDiscovery = null) + { + IGameExecutableDiscoveryService resolvedExecutableDiscovery = + executableDiscovery ?? Substitute.For(); + if (executableDiscovery == null) + { + resolvedExecutableDiscovery.IsExecutableAvailable(Arg.Any()).Returns(true); + } + + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedAddonsVersions().Returns(Array.Empty()); + catalogQueries.GetSelectedModVersions().Returns(Array.Empty()); + catalogQueries.GetSelectedAddonsForSelectedMod().Returns(Array.Empty()); + + return new LauncherLaunchReadinessCoordinator( + resolvedExecutableDiscovery, + catalogQueries, + dialogService, + CreateStringLocalizer()); + } + + private static IGameProcessLaunchOperation CreateProcessOperation(GameLaunchResult result) + { + return new TestGameProcessLaunchOperation(result); + } + + private static LauncherManualImportCoordinator CreateManualImportCoordinator( + LauncherManualImportResult? manualImportResult) + { + ILauncherFilePicker filePicker = new StubLauncherFilePicker(manualImportResult == null + ? Array.Empty() + : new[] { @"C:\Downloads\manual.zip" }); + ILauncherDialogService dialogService = Substitute.For(); + if (manualImportResult != null) + { + ModificationVersion version = manualImportResult.Modification.ModificationVersions[0]; + dialogService.ShowManualModificationImport( + Arg.Any(), + Arg.Any()) + .Returns(new ManualModificationDialogResult( + new[] { @"C:\Downloads\manual.zip" }, + null, + manualImportResult.Modification.Name, + version.Version)); + } + + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetMods().Returns(manualImportResult == null + ? Array.Empty() + : new[] { manualImportResult.Modification }); + catalogQueries.GetAllModsVersionsList().Returns(Array.Empty()); + + return new LauncherManualImportCoordinator( + filePicker, + dialogService, + catalogQueries, + Substitute.For(), + CreatePaths(), + new LauncherContentLayout("Addons", "Patches"), + Substitute.For(), + CreateIntegrityCoordinator(new LauncherPackageActivityService(), dialogService), + NullLogger.Instance); + } + + private static LauncherModificationDownloadCoordinator CreateDownloadCoordinator( + LauncherPackageActivityService packageActivityService, + ILauncherDialogService dialogService) + { + ILauncherPreferencesService preferencesService = Substitute.For(); + preferencesService.Current.Returns(new LauncherPreferences()); + return new LauncherModificationDownloadCoordinator( + preferencesService, + Substitute.For(), + Substitute.For(), + Substitute.For(), + CreateIntegrityCoordinator(packageActivityService, dialogService), + packageActivityService, + dialogService, + CreateStringLocalizer(), + NullLogger.Instance); + } + + private static LauncherShellNavigationCoordinator CreateShellNavigationCoordinator( + ILauncherShellService shellService, + ILauncherDialogService dialogService) + { + return new LauncherShellNavigationCoordinator( + CreatePaths(), + new LauncherContentLayout("Addons", "Patches"), + Substitute.For(), + new TestLauncherModsContext(), + CreateStringLocalizer(), + shellService, + dialogService, + NullLogger.Instance); + } + + private static LaunchContentIntegrityCoordinator CreateIntegrityCoordinator( + LauncherPackageActivityService packageActivityService, + ILauncherDialogService dialogService) + { + ILaunchContentIntegrityResolutionService resolutionService = + Substitute.For(); + resolutionService.CaptureManualImageSnapshotAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.CompletedTask); + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetAllModsVersionsList().Returns(Array.Empty()); + return new LaunchContentIntegrityCoordinator( + resolutionService, + catalogQueries, + CreatePaths(), + new LauncherContentLayout("Addons", "Patches"), + packageActivityService, + CreateStringLocalizer(), + dialogService, + NullLogger.Instance); + } + + private static LauncherVisualThemeService CreateVisualThemeService() + { + LauncherRuntimeContext runtimeContext = new(CreatePaths(), "1.0") + { + DefaultColors = CreateColors(), + Colors = CreateColors() + }; + + return new LauncherVisualThemeService( + runtimeContext, + Substitute.For(), + NullLogger.Instance); + } + + private static MainWindowViewModel CreateMainWindowViewModel() + { + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetMods().Returns(Array.Empty()); + catalogQueries.GetPatchesForSelectedMod().Returns(Array.Empty()); + catalogQueries.GetAddonsForSelectedMod().Returns(Array.Empty()); + catalogQueries.GetSelectedAddonsForSelectedMod().Returns(Array.Empty()); + catalogQueries.GetSelectedAddonsVersions().Returns(Array.Empty()); + ILauncherContentCatalogLoader catalogLoader = Substitute.For(); + catalogLoader.ReposModsNames.Returns(new[] { "Shockwave" }); + ILauncherContentCatalogCommands catalogCommands = Substitute.For(); + LauncherRuntimeContext runtimeContext = new(CreatePaths(), "1.0") + { + DefaultColors = CreateColors(), + Colors = CreateColors() + }; + TestStringLocalizer stringLocalizer = CreateStringLocalizer(); + + ILauncherPreferencesService preferencesService = Substitute.For(); + preferencesService.Current.Returns(new LauncherPreferences()); + + return new MainWindowViewModel( + preferencesService, + new LauncherContentListService(catalogQueries, catalogCommands, runtimeContext), + new LauncherContentViewStateService(catalogQueries, runtimeContext), + new LauncherTabStateService(catalogQueries, runtimeContext, stringLocalizer), + new LauncherExecutableSelectionService(Substitute.For(), runtimeContext, stringLocalizer), + new LauncherSelectedContentService(catalogQueries), + catalogLoader, + catalogQueries, + catalogCommands, + runtimeContext, + runtimeContext, + stringLocalizer, + new ModificationViewModelFactory( + new ModificationImageSourceFactory(NullLogger.Instance), + runtimeContext, + Substitute.For(), + stringLocalizer, + NullLogger.Instance), + new LauncherPackageActivityService()); + } + + private static ModificationViewModel CreateTile(GameModification modification) + { + return new ModificationViewModel( + modification, + new ModificationImageSourceFactory(NullLogger.Instance), + new TestLauncherModsContext(colors: CreateColors()), + Substitute.For(), + CreateStringLocalizer(), + NullLogger.Instance); + } + + private static GameModification CreateModification( + string name, + ModificationType modificationType, + ModificationVersion version, + string simpleDownloadLink = "", + string supportLink = "") + { + version.ModificationType = modificationType; + return new GameModification(version) + { + Name = name, + ModificationType = modificationType, + SimpleDownloadLink = simpleDownloadLink, + SupportLink = supportLink, + ModificationVersions = new List { version } + }; + } + + private static ModificationVersion CreateVersion( + string name, + ContentSourceKind sourceKind) + { + return new ModificationVersion + { + Name = name, + Version = "1.0", + Installed = true, + IsSelected = true, + ModificationType = ModificationType.Mod, + ContentSourceKind = sourceKind, + SimpleDownloadLink = sourceKind == ContentSourceKind.ManagedSingleFile + ? "https://example.test/package.zip" + : string.Empty + }; + } + + private static TestStringLocalizer CreateStringLocalizer() + { + return new TestStringLocalizer(new Dictionary + { + ["CancelDownload"] = "Cancel download", + ["CancelDownloadDetails"] = "Cancel {0}", + ["CancelLaunch"] = "Cancel launch", + ["Canceled"] = "Canceled", + ["CloseAnyway"] = "Close anyway", + ["ClosePackageActivityDetails"] = "Close {0}?", + ["Compatibility"] = "Compatibility", + ["Deprecated"] = "{0} is deprecated", + ["Discord"] = "Discord", + ["Error"] = "Error: ", + ["FilesCorrupted"] = "Files corrupted", + ["FinishProcess"] = "Finish process", + ["ForceQuitRunningProcess"] = "Force quit", + ["ForceQuitRunningProcessConfirmationDetails"] = "Force quit {0}?", + ["ForceQuitRunningProcessConfirmationTitle"] = "Force quit?", + ["GameIsStillRunning"] = "Game running", + ["GameRunning"] = "Game running", + ["Image"] = "Image", + ["Install"] = "Install", + ["InstallInProgress"] = "{0} installing", + ["IntegrityCacheSuffix"] = " cache", + ["LaunchAborted"] = "Launch aborted", + ["LaunchVerificationRunning"] = "Verification running", + ["LatestVersion"] = "Latest version: ", + ["ModDb"] = "Mod DB", + ["ModificationsWithUpdate"] = "Updates", + ["ModIsUpToDate"] = "Up to date", + ["NoSupportedClient"] = "No client", + ["NoWorldBuildersFound"] = "No World Builder", + ["NotInstalled"] = "{0} missing", + ["PackageActivityInProgress"] = "Package activity", + ["PackageActivityInProgressDetails"] = "Package activity details", + ["Preparing"] = "Preparing", + ["Reinstall"] = "Reinstall", + ["RunningProcessCloseBlockedDetails"] = "Close process first", + ["RunningProcessCloseBlockedTitle"] = "Process running", + ["RunningProcessUnknown"] = "Unknown process", + ["SetImage"] = "Set image", + ["ThankYou"] = "Thank you", + ["UninstalledUpdate"] = "{0} update missing", + ["Update"] = "Update", + ["UpToDate"] = "Up to date", + ["WorldBuilderRunning"] = "World Builder running", + ["Yes"] = "Yes", + }); + } + + private static ColorsInfo CreateColors() + { + return new ColorsInfo( + "#00E3FF", + "DarkGray", + "#7A7DB0", + "#BAFF0C", + "#232977", + "#090502", + "#B3000000", + "White", + "Black", + "#F21D2057", + "#E61D2057", + "#2534FF"); + } + + private static LauncherPaths CreatePaths() + { + return new LauncherPaths( + @"C:\Games\ZeroHour", + @"C:\Games\ZeroHour\GenLauncherGO", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache\Images", + @"C:\Games\ZeroHour\GenLauncherGO\Mods", + @"C:\Games\ZeroHour\GenLauncherGO\Logs", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Temp", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Deployment"); + } + + private sealed class StubLauncherFilePicker : ILauncherFilePicker + { + private readonly IReadOnlyList _files; + private readonly string? _pickedImageFile; + + public StubLauncherFilePicker() + : this(Array.Empty()) + { + } + + public StubLauncherFilePicker( + IReadOnlyList? files = null, + string? pickedImageFile = null) + { + _files = files ?? Array.Empty(); + _pickedImageFile = pickedImageFile; + } + + public IReadOnlyList PickManualPackageFiles(Window owner) + { + return _files; + } + + public string? PickModificationImageFile(Window owner, string imageFilterLabel) + { + return _pickedImageFile; + } + } + + private sealed class TestGameProcessLaunchOperation : IGameProcessLaunchOperation + { + public TestGameProcessLaunchOperation(GameLaunchResult result) + { + ArgumentNullException.ThrowIfNull(result); + + ExecutableName = result.ExecutableName; + Completion = Task.FromResult(result); + } + + public string ExecutableName { get; } + + public string CurrentExecutableName => ExecutableName; + + public event EventHandler? CurrentExecutableNameChanged + { + add { } + remove { } + } + + public Task Completion { get; } + + public void ForceClose() + { + } + } + + private sealed class StubWorkflowView : ILauncherWindowWorkflowView + { + public Window OwnerWindow { get; } = new(); + + public IReadOnlyList SelectedModifications { get; init; } = + Array.Empty(); + + public IReadOnlyList SelectedPatches { get; init; } = + Array.Empty(); + + public IReadOnlyList SelectedAddons { get; init; } = + Array.Empty(); + + public IReadOnlyList SelectedVersions { get; init; } = + Array.Empty(); + + public IReadOnlyList SelectedIntegrityProgressTargets { get; init; } = + Array.Empty(); + + public List AddRepositoryModificationNames { get; } = new(); + + public List ImportedResults { get; } = new(); + + public int DisableUiCount { get; private set; } + + public int EnableUiCount { get; private set; } + + public int EnsureContentSelectedCount { get; private set; } + + public int RestoreFocusesCount { get; private set; } + + public int RefreshAddonAndPatchTabLabelsCount { get; private set; } + + public int RemoveContentFromListCount { get; private set; } + + public int RefreshTabsCount { get; private set; } + + public List RunningProcessOverlayNames { get; } = new(); + + public int HideRunningProcessOverlayCount { get; private set; } + + public Task AddRepositoryModificationToListAsync(string modificationName) + { + AddRepositoryModificationNames.Add(modificationName); + return Task.CompletedTask; + } + + public void AddImportedContentToList(LauncherManualImportResult importResult) + { + ImportedResults.Add(importResult); + } + + public void DisableUi() + { + DisableUiCount++; + } + + public void EnableUi() + { + EnableUiCount++; + } + + public void ShowRunningProcessOverlay(string processName) + { + RunningProcessOverlayNames.Add(processName); + } + + public void HideRunningProcessOverlay() + { + HideRunningProcessOverlayCount++; + } + + public void EnsureContentSelected(ModificationViewModel modification) + { + EnsureContentSelectedCount++; + } + + public void RemoveContentFromList(ModificationViewModel modification) + { + RemoveContentFromListCount++; + } + + public void RefreshTabs() + { + RefreshTabsCount++; + } + + public void RefreshAddonAndPatchTabLabels() + { + RefreshAddonAndPatchTabLabelsCount++; + } + + public void RestoreFocuses() + { + RestoreFocusesCount++; + } + } + + private sealed class CompletingPackageDownloadOperation : IPackageDownloadOperation + { + public event Action? ProgressChanged + { + add { } + remove { } + } + + public event Action? Done; + + public int CancelDownloadCount { get; private set; } + + public void CancelDownload() + { + CancelDownloadCount++; + Done?.Invoke(new PackageDownloadResult { Canceled = true }); + } + + public void Dispose() + { + } + + public PackageDownloadReadiness GetPackageDownloadReadiness() + { + return new PackageDownloadReadiness { ReadyToDownload = true }; + } + + public PackageDownloadResult GetResult() + { + return new PackageDownloadResult(); + } + + public Task StartDownloadModificationAsync() + { + return Task.CompletedTask; + } + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Services/WpfLauncherFilePickerTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Services/WpfLauncherFilePickerTests.cs new file mode 100644 index 00000000..20d9df50 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Services/WpfLauncherFilePickerTests.cs @@ -0,0 +1,15 @@ +using GenLauncherGO.UI.Features.Launcher.Services; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Services; + +public sealed class WpfLauncherFilePickerTests +{ + [Fact] + public void ManualPackageFilterIncludesGibPackages() + { + // Arrange, Act and Assert + WpfLauncherFilePicker.PackageFilter.Should().Contain("*.gib"); + WpfLauncherFilePicker.PackageFilter.Should().Contain("*.big"); + WpfLauncherFilePicker.PackageDefaultExtension.Should().Be(".big"); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Support/LauncherDragDropControllerTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Support/LauncherDragDropControllerTests.cs new file mode 100644 index 00000000..1dab5914 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Support/LauncherDragDropControllerTests.cs @@ -0,0 +1,357 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Windows; +using System.Windows.Controls; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Launcher.Support; +using GenLauncherGO.UI.Features.Mods; +using GenLauncherGO.UI.Shared.Themes; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Support; + +public sealed class LauncherDragDropControllerTests +{ + [Fact] + public void TryResolveDropMoveReturnsSourceAndTargetIndexesForValidMove() + { + StaTestRunner.Run(() => + { + // Arrange + LauncherDragDropController controller = new(); + ModificationViewModel source = CreateTile("Source"); + ModificationViewModel target = CreateTile("Target"); + var modsList = new ListBox(); + modsList.Items.Add(source); + modsList.Items.Add(target); + var targetItem = new ListBoxItem { DataContext = target }; + DragEventArgs args = CreateDragEventArgs(source); + + // Act + bool result = controller.TryResolveDropMove( + targetItem, + args, + modsList, + out ModificationViewModel resolvedSource, + out int sourceIndex, + out int targetIndex); + + // Assert + result.Should().BeTrue(); + resolvedSource.Should().BeSameAs(source); + sourceIndex.Should().Be(0); + targetIndex.Should().Be(1); + }); + } + + [Fact] + public void TryResolveDropMoveReturnsFalseWhenSourceAndTargetMatch() + { + StaTestRunner.Run(() => + { + // Arrange + LauncherDragDropController controller = new(); + ModificationViewModel source = CreateTile("Source"); + var modsList = new ListBox(); + modsList.Items.Add(source); + var targetItem = new ListBoxItem { DataContext = source }; + DragEventArgs args = CreateDragEventArgs(source); + + // Act + bool result = controller.TryResolveDropMove( + targetItem, + args, + modsList, + out _, + out int sourceIndex, + out int targetIndex); + + // Assert + result.Should().BeFalse(); + sourceIndex.Should().Be(0); + targetIndex.Should().Be(0); + }); + } + + [Fact] + public void TryResolveDropMoveReturnsFalseForInvalidDropData() + { + StaTestRunner.Run(() => + { + // Arrange + LauncherDragDropController controller = new(); + ModificationViewModel target = CreateTile("Target"); + var modsList = new ListBox(); + modsList.Items.Add(target); + var targetItem = new ListBoxItem { DataContext = target }; + DragEventArgs args = CreateDragEventArgs("not a tile"); + + // Act + bool result = controller.TryResolveDropMove( + targetItem, + args, + modsList, + out ModificationViewModel resolvedSource, + out int sourceIndex, + out int targetIndex); + + // Assert + result.Should().BeFalse(); + resolvedSource.Should().BeNull(); + sourceIndex.Should().Be(-1); + targetIndex.Should().Be(-1); + }); + } + + [Fact] + public void TryResolveDropMoveReturnsFalseForInvalidSender() + { + StaTestRunner.Run(() => + { + // Arrange + LauncherDragDropController controller = new(); + ModificationViewModel source = CreateTile("Source"); + var modsList = new ListBox(); + modsList.Items.Add(source); + DragEventArgs args = CreateDragEventArgs(source); + + // Act + bool result = controller.TryResolveDropMove( + new object(), + args, + modsList, + out ModificationViewModel resolvedSource, + out int sourceIndex, + out int targetIndex); + + // Assert + result.Should().BeFalse(); + resolvedSource.Should().BeNull(); + sourceIndex.Should().Be(-1); + targetIndex.Should().Be(-1); + }); + } + + [Fact] + public void TryResolveDropMoveReturnsFalseWhenSourceIsNotInList() + { + StaTestRunner.Run(() => + { + // Arrange + LauncherDragDropController controller = new(); + ModificationViewModel source = CreateTile("Source"); + ModificationViewModel target = CreateTile("Target"); + var modsList = new ListBox(); + modsList.Items.Add(target); + var targetItem = new ListBoxItem { DataContext = target }; + DragEventArgs args = CreateDragEventArgs(source); + + // Act + bool result = controller.TryResolveDropMove( + targetItem, + args, + modsList, + out ModificationViewModel resolvedSource, + out int sourceIndex, + out int targetIndex); + + // Assert + result.Should().BeFalse(); + resolvedSource.Should().BeSameAs(source); + sourceIndex.Should().Be(-1); + targetIndex.Should().Be(0); + }); + } + + [Fact] + public void TryResolveDropMoveReturnsFalseWhenTargetIsNotInList() + { + StaTestRunner.Run(() => + { + // Arrange + LauncherDragDropController controller = new(); + ModificationViewModel source = CreateTile("Source"); + ModificationViewModel target = CreateTile("Target"); + var modsList = new ListBox(); + modsList.Items.Add(source); + var targetItem = new ListBoxItem { DataContext = target }; + DragEventArgs args = CreateDragEventArgs(source); + + // Act + bool result = controller.TryResolveDropMove( + targetItem, + args, + modsList, + out ModificationViewModel resolvedSource, + out int sourceIndex, + out int targetIndex); + + // Assert + result.Should().BeFalse(); + resolvedSource.Should().BeSameAs(source); + sourceIndex.Should().Be(0); + targetIndex.Should().Be(-1); + }); + } + + [Fact] + public void TryResolveDropMoveThrowsForNullArguments() + { + StaTestRunner.Run(() => + { + // Arrange + LauncherDragDropController controller = new(); + var modsList = new ListBox(); + DragEventArgs args = CreateDragEventArgs(CreateTile("Source")); + + // Act + Action nullEvent = () => controller.TryResolveDropMove( + new ListBoxItem(), + null!, + modsList, + out _, + out _, + out _); + Action nullList = () => controller.TryResolveDropMove( + new ListBoxItem(), + args, + null!, + out _, + out _, + out _); + + // Assert + nullEvent.Should().Throw().WithParameterName("e"); + nullList.Should().Throw().WithParameterName("modsList"); + }); + } + + [Fact] + public void ScrollDuringDragReturnsWhenListHasNoScrollViewer() + { + StaTestRunner.Run(() => + { + // Arrange + var modsList = new ListBox(); + DragEventArgs args = CreateDragEventArgs(CreateTile("Source")); + + // Act + Action act = () => LauncherDragDropController.ScrollDuringDrag(modsList, args); + + // Assert + act.Should().NotThrow(); + }); + } + + [Fact] + public void ScrollDuringDragThrowsForNullArguments() + { + StaTestRunner.Run(() => + { + // Arrange + var modsList = new ListBox(); + DragEventArgs args = CreateDragEventArgs(CreateTile("Source")); + + // Act + Action nullList = () => LauncherDragDropController.ScrollDuringDrag(null!, args); + Action nullEvent = () => LauncherDragDropController.ScrollDuringDrag(modsList, null!); + + // Assert + nullList.Should().Throw().WithParameterName("modsList"); + nullEvent.Should().Throw().WithParameterName("e"); + }); + } + + [Fact] + public void PreviewWindowOperationsReturnWhenPreviewHasNotBeenCreated() + { + // Arrange + LauncherDragDropController controller = new(); + + // Act + Action terminate = controller.TerminateDragDropWindow; + Action move = controller.MovePreviewToCursor; + + // Assert + terminate.Should().NotThrow(); + move.Should().NotThrow(); + } + + [Fact] + public void CaptureDragStartThrowsForNullEvent() + { + // Arrange + LauncherDragDropController controller = new(); + + // Act + Action act = () => controller.CaptureDragStart(null!); + + // Assert + act.Should().Throw().WithParameterName("e"); + } + + private static DragEventArgs CreateDragEventArgs(object data) + { + DataObject dataObject = new(); + dataObject.SetData(data.GetType(), data); + return (DragEventArgs)Activator.CreateInstance( + typeof(DragEventArgs), + BindingFlags.Instance | BindingFlags.NonPublic, + binder: null, + args: new object?[] + { + dataObject, + DragDropKeyStates.None, + DragDropEffects.Move, + null, + new Point(), + }, + culture: null)!; + } + + private static ModificationViewModel CreateTile(string name) + { + ModificationVersion version = new() + { + Name = name, + Version = "1.0", + Installed = true, + IsSelected = true, + ModificationType = ModificationType.Mod, + }; + GameModification modification = new(version) + { + Name = name, + ModificationType = ModificationType.Mod, + ModificationVersions = new List { version }, + }; + + return new ModificationViewModel( + modification, + new ModificationImageSourceFactory(NullLogger.Instance), + new TestLauncherModsContext(colors: CreateColors()), + Substitute.For(), + new TestStringLocalizer(), + NullLogger.Instance); + } + + private static ColorsInfo CreateColors() + { + return new ColorsInfo( + "#00E3FF", + "DarkGray", + "#7A7DB0", + "#BAFF0C", + "#232977", + "#090502", + "#B3000000", + "White", + "Black", + "#F21D2057", + "#E61D2057", + "#2534FF"); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Support/LauncherSelectionControllerTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Support/LauncherSelectionControllerTests.cs new file mode 100644 index 00000000..47deb4a7 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Support/LauncherSelectionControllerTests.cs @@ -0,0 +1,816 @@ +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Launcher.Support; +using GenLauncherGO.UI.Features.Mods; +using GenLauncherGO.UI.Features.Startup; +using GenLauncherGO.UI.Shared.Themes; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Support; + +public sealed class LauncherSelectionControllerTests +{ + [Fact] + public void SelectionControllerFactoryConstructorThrowsForMissingDependencies() + { + // Arrange + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + ILauncherContentCatalogCommands catalogCommands = Substitute.For(); + LauncherTileVersionSelectionService tileVersionSelectionService = new(); + LauncherVisualThemeService visualThemeService = CreateVisualThemeService(); + + // Act + Action missingQueries = () => new LauncherSelectionControllerFactory( + null!, + catalogCommands, + tileVersionSelectionService, + visualThemeService); + Action missingCommands = () => new LauncherSelectionControllerFactory( + catalogQueries, + null!, + tileVersionSelectionService, + visualThemeService); + Action missingVersionSelection = () => new LauncherSelectionControllerFactory( + catalogQueries, + catalogCommands, + null!, + visualThemeService); + Action missingTheme = () => new LauncherSelectionControllerFactory( + catalogQueries, + catalogCommands, + tileVersionSelectionService, + null!); + + // Assert + missingQueries.Should().Throw(); + missingCommands.Should().Throw(); + missingVersionSelection.Should().Throw(); + missingTheme.Should().Throw(); + } + + [Fact] + public void SelectionControllerFactoryCreatesControllerForLauncherLists() + { + RunOnStaThread(() => + { + // Arrange + LauncherSelectionControllerFactory factory = new( + Substitute.For(), + Substitute.For(), + new LauncherTileVersionSelectionService(), + CreateVisualThemeService()); + var modsList = new ListBox { SelectionMode = SelectionMode.Multiple }; + var patchesList = new ListBox { SelectionMode = SelectionMode.Multiple }; + var addonsList = new ListBox { SelectionMode = SelectionMode.Multiple }; + + // Act + LauncherSelectionController controller = factory.Create( + modsList, + patchesList, + addonsList, + () => { }, + () => { }, + () => { }, + () => { }, + () => { }, + () => { }, + () => { }, + _ => Task.CompletedTask); + + // Assert + controller.Should().NotBeNull(); + }); + } + + [Fact] + public void HandleModsListSelectionChangedDoesNotClearSavedChildSelectionsDuringRefresh() + { + RunOnStaThread(() => + { + // Arrange + var modsList = new ListBox { Name = "ModsList", SelectionMode = SelectionMode.Multiple }; + var patchesList = new ListBox { Name = "PatchesList", SelectionMode = SelectionMode.Multiple }; + var addonsList = new ListBox { Name = "AddonsList", SelectionMode = SelectionMode.Multiple }; + + ModificationViewModel newMod = CreateViewModel("New Mod", ModificationType.Mod); + ModificationViewModel savedPatch = CreateViewModel("Saved Patch", ModificationType.Patch, "Old Mod"); + ModificationViewModel savedAddon = CreateViewModel("Saved Addon", ModificationType.Addon, "Old Mod"); + savedPatch.ContainerModification.IsSelected = true; + savedAddon.ContainerModification.IsSelected = true; + + modsList.ItemsSource = new[] { newMod }; + patchesList.ItemsSource = new[] { savedPatch }; + addonsList.ItemsSource = new[] { savedAddon }; + + LauncherSelectionController controller = CreateController( + modsList, + patchesList, + addonsList, + updateTabs: () => + { + RaiseRemovedSelectionWithReplacement( + patchesList, + savedPatch, + CreateViewModel("Replacement Patch", ModificationType.Patch, "New Mod")); + RaiseRemovedSelectionWithReplacement( + addonsList, + savedAddon, + CreateViewModel("Replacement Addon", ModificationType.Addon, "New Mod")); + }); + patchesList.SelectionChanged += controller.HandlePatchesListSelectionChanged; + addonsList.SelectionChanged += controller.HandleAddonsListSelectionChanged; + + Task? selectionTask = null; + modsList.SelectionChanged += (_, args) => + selectionTask = controller.HandleModsListSelectionChangedAsync(args); + + // Act + modsList.SelectedItem = newMod; + selectionTask?.GetAwaiter().GetResult(); + + // Assert + savedPatch.ContainerModification.IsSelected.Should().BeTrue(); + savedAddon.ContainerModification.IsSelected.Should().BeTrue(); + }); + } + + [Fact] + public void HandleModsListSelectionChangedWhenSelectionIsRemovedClearsCatalogAndRestoresDefaults() + { + RunOnStaThread(() => + { + // Arrange + var modsList = new ListBox { Name = "ModsList", SelectionMode = SelectionMode.Multiple }; + var patchesList = new ListBox { Name = "PatchesList", SelectionMode = SelectionMode.Multiple }; + var addonsList = new ListBox { Name = "AddonsList", SelectionMode = SelectionMode.Multiple }; + ModificationViewModel removedMod = CreateViewModel("Selected Mod", ModificationType.Mod); + modsList.ItemsSource = new[] { removedMod }; + ILauncherContentCatalogCommands catalogCommands = Substitute.For(); + ColorsInfo defaultColors = new(); + ColorsInfo selectedColors = new(); + LauncherRuntimeContext runtimeContext = CreateRuntimeContext(defaultColors); + runtimeContext.Colors = selectedColors; + int updateTabsCount = 0; + int updateVisualsCount = 0; + int setFocusesCount = 0; + LauncherSelectionController controller = CreateController( + modsList, + patchesList, + addonsList, + updateTabs: () => updateTabsCount++, + catalogCommands: catalogCommands, + runtimeContext: runtimeContext, + updateVisuals: () => updateVisualsCount++, + setFocuses: () => setFocusesCount++); + Task selectionTask = Task.CompletedTask; + modsList.SelectionChanged += (_, args) => + selectionTask = controller.HandleModsListSelectionChangedAsync(args); + removedMod.ContainerModification.IsSelected = true; + + // Act + modsList.RaiseEvent(new SelectionChangedEventArgs( + Selector.SelectionChangedEvent, + new List { removedMod }, + new List())); + selectionTask.GetAwaiter().GetResult(); + + // Assert + runtimeContext.Colors.Should().BeSameAs(defaultColors); + updateTabsCount.Should().BeGreaterThan(0); + updateVisualsCount.Should().BeGreaterThan(0); + setFocusesCount.Should().BeGreaterThan(0); + catalogCommands.Received().UnselectAllModifications(); + }); + } + + [Fact] + public void HandleModsListSelectionChangedIgnoresNonListBoxEvents() + { + RunOnStaThread(() => + { + // Arrange + var modsList = new ListBox { Name = "ModsList", SelectionMode = SelectionMode.Multiple }; + var patchesList = new ListBox { Name = "PatchesList", SelectionMode = SelectionMode.Multiple }; + var addonsList = new ListBox { Name = "AddonsList", SelectionMode = SelectionMode.Multiple }; + int setFocusesCount = 0; + LauncherSelectionController controller = CreateController( + modsList, + patchesList, + addonsList, + updateTabs: () => { }, + setFocuses: () => setFocusesCount++); + var args = new SelectionChangedEventArgs( + Selector.SelectionChangedEvent, + new List(), + new List()); + + // Act + controller.HandleModsListSelectionChangedAsync(args).GetAwaiter().GetResult(); + + // Assert + setFocusesCount.Should().Be(1); + }); + } + + [Fact] + public void HandleModsListSelectionChangedIgnoresInvalidAddedAndRemovedItems() + { + RunOnStaThread(() => + { + // Arrange + var modsList = new ListBox { Name = "ModsList", SelectionMode = SelectionMode.Multiple }; + var patchesList = new ListBox { Name = "PatchesList", SelectionMode = SelectionMode.Multiple }; + var addonsList = new ListBox { Name = "AddonsList", SelectionMode = SelectionMode.Multiple }; + int updateTabsCount = 0; + int updateVisualsCount = 0; + LauncherSelectionController controller = CreateController( + modsList, + patchesList, + addonsList, + updateTabs: () => updateTabsCount++, + updateVisuals: () => updateVisualsCount++); + Task selectionTask = Task.CompletedTask; + modsList.SelectionChanged += (_, args) => + selectionTask = controller.HandleModsListSelectionChangedAsync(args); + + var invalidAddedArgs = new SelectionChangedEventArgs( + Selector.SelectionChangedEvent, + new List(), + new List { "not a modification" }); + var invalidRemovedArgs = new SelectionChangedEventArgs( + Selector.SelectionChangedEvent, + new List { "not a modification" }, + new List()); + + // Act + modsList.RaiseEvent(invalidAddedArgs); + selectionTask.GetAwaiter().GetResult(); + modsList.RaiseEvent(invalidRemovedArgs); + selectionTask.GetAwaiter().GetResult(); + + // Assert + invalidAddedArgs.Handled.Should().BeFalse(); + invalidRemovedArgs.Handled.Should().BeFalse(); + updateTabsCount.Should().Be(0); + updateVisualsCount.Should().Be(0); + }); + } + + [Fact] + public void HandleModsListSelectionChangedUsesSavedSelectionWithoutRefreshingChildren() + { + RunOnStaThread(() => + { + // Arrange + var modsList = new ListBox { Name = "ModsList", SelectionMode = SelectionMode.Multiple }; + var patchesList = new ListBox { Name = "PatchesList", SelectionMode = SelectionMode.Multiple }; + var addonsList = new ListBox { Name = "AddonsList", SelectionMode = SelectionMode.Multiple }; + ModificationViewModel selectedMod = CreateViewModel("Selected Mod", ModificationType.Mod); + modsList.ItemsSource = new[] { selectedMod }; + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedMod().Returns(selectedMod.ContainerModification); + int disableCount = 0; + int enableCount = 0; + int refreshCount = 0; + LauncherSelectionController controller = CreateController( + modsList, + patchesList, + addonsList, + updateTabs: () => { }, + catalogQueries: catalogQueries, + disableUi: () => disableCount++, + enableUi: () => enableCount++, + updateAddonsAndPatchesAsync: _ => + { + refreshCount++; + return Task.CompletedTask; + }); + Task selectionTask = Task.CompletedTask; + modsList.SelectionChanged += (_, args) => + selectionTask = controller.HandleModsListSelectionChangedAsync(args); + + // Act + modsList.SelectedItem = selectedMod; + selectionTask.GetAwaiter().GetResult(); + + // Assert + selectedMod.ContainerModification.IsSelected.Should().BeTrue(); + disableCount.Should().Be(0); + enableCount.Should().Be(0); + refreshCount.Should().Be(0); + }); + } + + [Fact] + public void HandlePatchesListSelectionChangedSelectsAndUnselectsPatch() + { + RunOnStaThread(() => + { + // Arrange + var modsList = new ListBox { Name = "ModsList", SelectionMode = SelectionMode.Multiple }; + var patchesList = new ListBox { Name = "PatchesList", SelectionMode = SelectionMode.Multiple }; + var addonsList = new ListBox { Name = "AddonsList", SelectionMode = SelectionMode.Multiple }; + ModificationViewModel patch = CreateViewModel("Patch", ModificationType.Patch, "Selected Mod"); + patchesList.ItemsSource = new[] { patch }; + int updateLabelsCount = 0; + LauncherSelectionController controller = CreateController( + modsList, + patchesList, + addonsList, + updateTabs: () => { }, + updateAddonAndPatchTabLabels: () => updateLabelsCount++); + patchesList.SelectionChanged += controller.HandlePatchesListSelectionChanged; + + // Act + patchesList.SelectedItem = patch; + patchesList.SelectedItem = null; + + // Assert + patch.ContainerModification.IsSelected.Should().BeFalse(); + updateLabelsCount.Should().Be(2); + }); + } + + [Fact] + public void HandlePatchesListSelectionChangedIgnoresWrongSourceAndEmptyLists() + { + RunOnStaThread(() => + { + // Arrange + var modsList = new ListBox { Name = "ModsList", SelectionMode = SelectionMode.Multiple }; + var patchesList = new ListBox { Name = "PatchesList", SelectionMode = SelectionMode.Multiple }; + var addonsList = new ListBox { Name = "AddonsList", SelectionMode = SelectionMode.Multiple }; + int updateLabelsCount = 0; + LauncherSelectionController controller = CreateController( + modsList, + patchesList, + addonsList, + updateTabs: () => { }, + updateAddonAndPatchTabLabels: () => updateLabelsCount++); + patchesList.SelectionChanged += controller.HandlePatchesListSelectionChanged; + var directArgs = new SelectionChangedEventArgs( + Selector.SelectionChangedEvent, + new List(), + new List { "Patch" }); + var emptyListArgs = new SelectionChangedEventArgs( + Selector.SelectionChangedEvent, + new List(), + new List { "Patch" }); + + // Act + controller.HandlePatchesListSelectionChanged(patchesList, directArgs); + patchesList.RaiseEvent(emptyListArgs); + + // Assert + updateLabelsCount.Should().Be(0); + }); + } + + [Fact] + public void HandlePatchesListSelectionChangedIgnoresInvalidAddedPatch() + { + RunOnStaThread(() => + { + // Arrange + var modsList = new ListBox { Name = "ModsList", SelectionMode = SelectionMode.Multiple }; + var patchesList = new ListBox { Name = "PatchesList", SelectionMode = SelectionMode.Multiple }; + var addonsList = new ListBox { Name = "AddonsList", SelectionMode = SelectionMode.Multiple }; + patchesList.ItemsSource = new[] { "not a patch" }; + int updateLabelsCount = 0; + LauncherSelectionController controller = CreateController( + modsList, + patchesList, + addonsList, + updateTabs: () => { }, + updateAddonAndPatchTabLabels: () => updateLabelsCount++); + patchesList.SelectionChanged += controller.HandlePatchesListSelectionChanged; + var args = new SelectionChangedEventArgs( + Selector.SelectionChangedEvent, + new List(), + new List { "not a patch" }); + + // Act + patchesList.RaiseEvent(args); + + // Assert + args.Handled.Should().BeFalse(); + updateLabelsCount.Should().Be(1); + }); + } + + [Fact] + public void HandlePatchesListSelectionChangedReplacesExistingPatchSelection() + { + RunOnStaThread(() => + { + // Arrange + var modsList = new ListBox { Name = "ModsList", SelectionMode = SelectionMode.Multiple }; + var patchesList = new ListBox { Name = "PatchesList", SelectionMode = SelectionMode.Multiple }; + var addonsList = new ListBox { Name = "AddonsList", SelectionMode = SelectionMode.Multiple }; + ModificationViewModel oldPatch = CreateViewModel("Old Patch", ModificationType.Patch, "Selected Mod"); + ModificationViewModel newPatch = CreateViewModel("New Patch", ModificationType.Patch, "Selected Mod"); + patchesList.ItemsSource = new[] { oldPatch, newPatch }; + patchesList.SelectedItems.Add(oldPatch); + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedPatch().Returns(oldPatch.ContainerModification); + int updateLabelsCount = 0; + LauncherSelectionController controller = CreateController( + modsList, + patchesList, + addonsList, + updateTabs: () => { }, + catalogQueries: catalogQueries, + updateAddonAndPatchTabLabels: () => updateLabelsCount++); + patchesList.SelectionChanged += controller.HandlePatchesListSelectionChanged; + var args = new SelectionChangedEventArgs( + Selector.SelectionChangedEvent, + new List(), + new List { newPatch }); + + // Act + patchesList.RaiseEvent(args); + + // Assert + oldPatch.ContainerModification.IsSelected.Should().BeFalse(); + newPatch.ContainerModification.IsSelected.Should().BeTrue(); + patchesList.SelectedItems.Count.Should().Be(1); + patchesList.SelectedItems[0].Should().BeSameAs(newPatch); + args.Handled.Should().BeTrue(); + updateLabelsCount.Should().Be(1); + }); + } + + [Fact] + public void HandleAddonsListSelectionChangedSelectsAndUnselectsAddon() + { + RunOnStaThread(() => + { + // Arrange + var modsList = new ListBox { Name = "ModsList", SelectionMode = SelectionMode.Multiple }; + var patchesList = new ListBox { Name = "PatchesList", SelectionMode = SelectionMode.Multiple }; + var addonsList = new ListBox { Name = "AddonsList", SelectionMode = SelectionMode.Multiple }; + ModificationViewModel addon = CreateViewModel("Addon", ModificationType.Addon, "Selected Mod"); + addonsList.ItemsSource = new[] { addon }; + int updateLabelsCount = 0; + LauncherSelectionController controller = CreateController( + modsList, + patchesList, + addonsList, + updateTabs: () => { }, + updateAddonAndPatchTabLabels: () => updateLabelsCount++); + addonsList.SelectionChanged += controller.HandleAddonsListSelectionChanged; + + // Act + addonsList.SelectedItem = addon; + addonsList.SelectedItem = null; + + // Assert + addon.ContainerModification.IsSelected.Should().BeFalse(); + updateLabelsCount.Should().Be(2); + }); + } + + [Fact] + public void HandleAddonsListSelectionChangedIgnoresWrongSourceEmptyListsAndInvalidAddedAddon() + { + RunOnStaThread(() => + { + // Arrange + var modsList = new ListBox { Name = "ModsList", SelectionMode = SelectionMode.Multiple }; + var patchesList = new ListBox { Name = "PatchesList", SelectionMode = SelectionMode.Multiple }; + var addonsList = new ListBox { Name = "AddonsList", SelectionMode = SelectionMode.Multiple }; + int updateLabelsCount = 0; + LauncherSelectionController controller = CreateController( + modsList, + patchesList, + addonsList, + updateTabs: () => { }, + updateAddonAndPatchTabLabels: () => updateLabelsCount++); + addonsList.SelectionChanged += controller.HandleAddonsListSelectionChanged; + var directArgs = new SelectionChangedEventArgs( + Selector.SelectionChangedEvent, + new List(), + new List { "Addon" }); + var emptyListArgs = new SelectionChangedEventArgs( + Selector.SelectionChangedEvent, + new List(), + new List { "Addon" }); + var invalidAddonArgs = new SelectionChangedEventArgs( + Selector.SelectionChangedEvent, + new List(), + new List { "not an addon" }); + + // Act + controller.HandleAddonsListSelectionChanged(addonsList, directArgs); + addonsList.RaiseEvent(emptyListArgs); + addonsList.ItemsSource = new[] { "not an addon" }; + addonsList.RaiseEvent(invalidAddonArgs); + + // Assert + invalidAddonArgs.Handled.Should().BeFalse(); + updateLabelsCount.Should().Be(0); + }); + } + + [Fact] + public void HandleVersionsListSelectionChangedSelectsRequestedVersion() + { + RunOnStaThread(() => + { + // Arrange + var modsList = new ListBox { Name = "ModsList", SelectionMode = SelectionMode.Multiple }; + var patchesList = new ListBox { Name = "PatchesList", SelectionMode = SelectionMode.Multiple }; + var addonsList = new ListBox { Name = "AddonsList", SelectionMode = SelectionMode.Multiple }; + ModificationViewModel viewModel = CreateViewModel("Selected Mod", ModificationType.Mod); + ModificationVersion oldVersion = viewModel.ContainerModification.ModificationVersions[0]; + ModificationVersion newVersion = new() + { + Name = "Selected Mod", + Version = "2.0", + ModificationType = ModificationType.Mod + }; + viewModel.ContainerModification.ModificationVersions.Add(newVersion); + ModificationVersionSelection selection = new(newVersion, "2.0", viewModel); + ComboBox comboBox = new(); + comboBox.ItemsSource = new[] { selection }; + comboBox.SelectedItem = selection; + int updateLabelsCount = 0; + LauncherSelectionController controller = CreateController( + modsList, + patchesList, + addonsList, + updateTabs: () => { }, + updateAddonAndPatchTabLabels: () => updateLabelsCount++); + + // Act + controller.HandleVersionsListSelectionChanged(comboBox); + + // Assert + oldVersion.IsSelected.Should().BeFalse(); + newVersion.IsSelected.Should().BeTrue(); + updateLabelsCount.Should().Be(1); + }); + } + + [Fact] + public void HandleVersionsListSelectionChangedIgnoresInvalidSenderAndEmptySelection() + { + RunOnStaThread(() => + { + // Arrange + var modsList = new ListBox { Name = "ModsList", SelectionMode = SelectionMode.Multiple }; + var patchesList = new ListBox { Name = "PatchesList", SelectionMode = SelectionMode.Multiple }; + var addonsList = new ListBox { Name = "AddonsList", SelectionMode = SelectionMode.Multiple }; + int updateLabelsCount = 0; + LauncherSelectionController controller = CreateController( + modsList, + patchesList, + addonsList, + updateTabs: () => { }, + updateAddonAndPatchTabLabels: () => updateLabelsCount++); + + // Act + controller.HandleVersionsListSelectionChanged(new object()); + controller.HandleVersionsListSelectionChanged(new ComboBox()); + + // Assert + updateLabelsCount.Should().Be(0); + }); + } + + [Fact] + public void RightClickSelectionRestoresTheSavedSelectionState() + { + RunOnStaThread(() => + { + // Arrange + var clearList = new ListBox { SelectionMode = SelectionMode.Multiple }; + var addedList = new ListBox { SelectionMode = SelectionMode.Multiple }; + var removedList = new ListBox { SelectionMode = SelectionMode.Multiple }; + object clearItem = new(); + object addedItem = new(); + object removedItem = new(); + clearList.ItemsSource = new[] { clearItem }; + addedList.ItemsSource = new[] { addedItem }; + removedList.ItemsSource = new[] { removedItem }; + clearList.SelectedItems.Add(clearItem); + addedList.SelectedItems.Add(addedItem); + + // Act + SelectionChangedEventArgs clearArgs = InvokeRightClickSelection( + clearList, + hasSavedSelection: false, + removedItems: new List(), + addedItems: new List { clearItem }); + SelectionChangedEventArgs addedArgs = InvokeRightClickSelection( + addedList, + hasSavedSelection: true, + removedItems: new List(), + addedItems: new List { addedItem }); + SelectionChangedEventArgs removedArgs = InvokeRightClickSelection( + removedList, + hasSavedSelection: true, + removedItems: new List { removedItem }, + addedItems: new List()); + + // Assert + clearList.SelectedItems.Count.Should().Be(0); + addedList.SelectedItems.Count.Should().Be(0); + removedList.SelectedItems.Count.Should().Be(1); + removedList.SelectedItems[0].Should().BeSameAs(removedItem); + clearArgs.Handled.Should().BeTrue(); + addedArgs.Handled.Should().BeTrue(); + removedArgs.Handled.Should().BeTrue(); + }); + } + + private static LauncherSelectionController CreateController( + ListBox modsList, + ListBox patchesList, + ListBox addonsList, + Action updateTabs, + ILauncherContentCatalogQueries? catalogQueries = null, + ILauncherContentCatalogCommands? catalogCommands = null, + LauncherRuntimeContext? runtimeContext = null, + Action? disableUi = null, + Action? enableUi = null, + Action? updateVisuals = null, + Action? updateAddonsList = null, + Action? updateAddonAndPatchTabLabels = null, + Action? setFocuses = null, + Func? updateAddonsAndPatchesAsync = null) + { + ILauncherContentCatalogQueries resolvedCatalogQueries = catalogQueries ?? CreateCatalogQueries(); + + LauncherRuntimeContext resolvedRuntimeContext = runtimeContext ?? CreateRuntimeContext(new ColorsInfo()); + LauncherVisualThemeService visualThemeService = new( + resolvedRuntimeContext, + Substitute.For(), + NullLogger.Instance); + + return new LauncherSelectionController( + modsList, + patchesList, + addonsList, + resolvedCatalogQueries, + catalogCommands ?? Substitute.For(), + new LauncherTileVersionSelectionService(), + visualThemeService, + disableUi ?? (() => { }), + enableUi ?? (() => { }), + updateTabs, + updateVisuals ?? (() => { }), + updateAddonsList ?? (() => { }), + updateAddonAndPatchTabLabels ?? (() => { }), + setFocuses ?? (() => { }), + updateAddonsAndPatchesAsync ?? (_ => Task.CompletedTask)); + } + + private static ILauncherContentCatalogQueries CreateCatalogQueries() + { + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedMod().Returns((GameModification?)null); + return catalogQueries; + } + + private static LauncherVisualThemeService CreateVisualThemeService() + { + return new LauncherVisualThemeService( + CreateRuntimeContext(new ColorsInfo()), + Substitute.For(), + NullLogger.Instance); + } + + private static void RaiseRemovedSelectionWithReplacement( + ListBox listBox, + ModificationViewModel removed, + ModificationViewModel replacement) + { + listBox.ItemsSource = new[] { replacement }; + listBox.RaiseEvent(new SelectionChangedEventArgs( + Selector.SelectionChangedEvent, + new List { removed }, + new List())); + } + + private static ModificationViewModel CreateViewModel( + string name, + ModificationType modificationType, + string dependenceName = "") + { + return new ModificationViewModel( + CreateModification(name, modificationType, dependenceName), + new ModificationImageSourceFactory(NullLogger.Instance), + new TestLauncherModsContext(), + Substitute.For(), + new TestStringLocalizer(), + NullLogger.Instance); + } + + private static SelectionChangedEventArgs InvokeRightClickSelection( + ListBox listBox, + bool hasSavedSelection, + List removedItems, + List addedItems) + { + var args = new SelectionChangedEventArgs( + Selector.SelectionChangedEvent, + removedItems, + addedItems); + object?[] parameters = + { + listBox, + hasSavedSelection, + args, + false, + }; + + typeof(LauncherSelectionController) + .GetMethod( + "HandleRightClickSelection", + BindingFlags.NonPublic | BindingFlags.Static)! + .Invoke(null, parameters); + + parameters[3].Should().Be(false); + return args; + } + + private static GameModification CreateModification( + string name, + ModificationType modificationType, + string dependenceName) + { + var version = new ModificationVersion + { + Name = name, + Version = "1.0", + ModificationType = modificationType, + DependenceName = dependenceName + }; + + return new GameModification(version) + { + Name = name, + ModificationType = modificationType, + DependenceName = dependenceName + }; + } + + private static LauncherRuntimeContext CreateRuntimeContext(ColorsInfo defaultColors) + { + return new LauncherRuntimeContext(CreatePaths(), "1.0") + { + DefaultColors = defaultColors, + Colors = defaultColors + }; + } + + private static LauncherPaths CreatePaths() + { + return new LauncherPaths( + @"C:\Games\ZeroHour", + @"C:\Games\ZeroHour\GenLauncherGO", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache\Images", + @"C:\Games\ZeroHour\GenLauncherGO\Mods", + @"C:\Games\ZeroHour\GenLauncherGO\Logs", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Temp", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Deployment"); + } + + private static void RunOnStaThread(Action action) + { + Exception? exception = null; + Thread thread = new(() => + { + try + { + action(); + } + catch (Exception caughtException) + { + exception = caughtException; + } + }); + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + + if (exception is not null) + { + throw exception; + } + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/Support/LauncherWindowListControllerTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/Support/LauncherWindowListControllerTests.cs new file mode 100644 index 00000000..00b2a45c --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/Support/LauncherWindowListControllerTests.cs @@ -0,0 +1,589 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Settings.Contracts; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Launcher.Models; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Launcher.Support; +using GenLauncherGO.UI.Features.Launcher.ViewModels; +using GenLauncherGO.UI.Features.Mods; +using GenLauncherGO.UI.Features.Mods.Contracts; +using GenLauncherGO.UI.Features.Startup; +using GenLauncherGO.UI.Shared.Themes; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.Support; + +public sealed class LauncherWindowListControllerTests +{ + [Fact] + public void EnsureContentSelectedAddsTileToMatchingList() + { + StaTestRunner.Run(() => + { + // Arrange + ModificationViewModel mod = CreateTile("Mod", ModificationType.Mod); + ModificationViewModel patch = CreateTile("Patch", ModificationType.Patch); + ModificationViewModel addon = CreateTile("Addon", ModificationType.Addon); + LauncherWindowListController controller = CreateController( + out ListBox modsList, + out ListBox patchesList, + out ListBox addonsList); + modsList.ItemsSource = new[] { mod }; + patchesList.ItemsSource = new[] { patch }; + addonsList.ItemsSource = new[] { addon }; + + // Act + controller.EnsureContentSelected(mod); + controller.EnsureContentSelected(patch); + controller.EnsureContentSelected(addon); + + // Assert + modsList.SelectedItems.Cast().Should().ContainSingle().Which.Should().BeSameAs(mod); + patchesList.SelectedItems.Cast().Should().ContainSingle().Which.Should().BeSameAs(patch); + addonsList.SelectedItems.Cast().Should().ContainSingle().Which.Should().BeSameAs(addon); + }); + } + + [Fact] + public void SelectedCollectionsReturnSelectedTilesAndProgressTargets() + { + StaTestRunner.Run(() => + { + // Arrange + ModificationViewModel mod = CreateTile("Mod", ModificationType.Mod); + ModificationViewModel patch = CreateTile("Patch", ModificationType.Patch); + ModificationViewModel addon = CreateTile("Addon", ModificationType.Addon); + LauncherWindowListController controller = CreateController( + out ListBox modsList, + out ListBox patchesList, + out ListBox addonsList); + modsList.ItemsSource = new[] { mod }; + patchesList.ItemsSource = new[] { patch }; + addonsList.ItemsSource = new[] { addon }; + modsList.SelectedItems.Add(mod); + patchesList.SelectedItems.Add(patch); + addonsList.SelectedItems.Add(addon); + + // Act and Assert + controller.SelectedModifications.Should().ContainSingle().Which.Should().BeSameAs(mod); + controller.SelectedPatches.Should().ContainSingle().Which.Should().BeSameAs(patch); + controller.SelectedAddons.Should().ContainSingle().Which.Should().BeSameAs(addon); + controller.SelectedIntegrityProgressTargets.Should().HaveCount(3); + }); + } + + [Fact] + public void SelectedVersionsReturnCatalogSelectionsInLaunchOrder() + { + StaTestRunner.Run(() => + { + // Arrange + ModificationVersion modVersion = CreateVersion("Mod", ModificationType.Mod); + ModificationVersion patchVersion = CreateVersion("Patch", ModificationType.Patch); + ModificationVersion addonVersion = CreateVersion("Addon", ModificationType.Addon); + ILauncherContentCatalogQueries catalogQueries = CreateCatalogQueries(); + catalogQueries.GetSelectedModVersion().Returns(modVersion); + catalogQueries.GetSelectedPatchVersion().Returns(patchVersion); + catalogQueries.GetSelectedAddonsVersions().Returns(new[] { addonVersion }); + LauncherWindowListController controller = CreateController( + out _, + out _, + out _, + out _, + out _, + catalogQueries: catalogQueries); + + // Act + IReadOnlyList selectedVersions = controller.SelectedVersions; + + // Assert + selectedVersions.Should().Equal(modVersion, patchVersion, addonVersion); + }); + } + + [Fact] + public void DisableAndEnableUiForwardToViewModelControlState() + { + StaTestRunner.Run(() => + { + // Arrange + LauncherWindowListController controller = CreateController( + out _, + out _, + out _, + out MainWindowViewModel viewModel); + + // Act + controller.DisableUi(); + bool disabled = viewModel.MainControlsEnabled; + controller.EnableUi(); + + // Assert + disabled.Should().BeFalse(); + viewModel.MainControlsEnabled.Should().BeTrue(); + }); + } + + [Fact] + public void InitializeWithoutSavedSelectionAppliesDefaultThemeResources() + { + StaTestRunner.Run(() => + { + // Arrange + LauncherWindowListController controller = CreateController( + out _, + out _, + out _, + out _, + out Window owner); + + // Act + controller.Initialize(); + + // Assert + owner.Resources.Contains("GenLauncherBorderColor").Should().BeTrue(); + owner.Resources.Contains("ButtonPressedBackground").Should().BeTrue(); + }); + } + + [Fact] + public void InitializeRestoresSavedModificationSelection() + { + StaTestRunner.Run(() => + { + // Arrange + GameModification savedModification = CreateModification("Saved Mod", ModificationType.Mod); + ILauncherContentCatalogQueries catalogQueries = CreateCatalogQueries( + mods: new[] { savedModification }, + selectedMod: savedModification); + LauncherWindowListController controller = CreateController( + out ListBox modsList, + out _, + out _, + out MainWindowViewModel viewModel, + out _, + catalogQueries: catalogQueries); + viewModel.RefreshModsList(); + modsList.ItemsSource = viewModel.ModsListSource; + + // Act + controller.Initialize(); + + // Assert + modsList.SelectedItem.Should().BeSameAs(viewModel.ModsListSource[0]); + }); + } + + [Fact] + public void RefreshTabsRebuildsChildContentListsWhenTabsAreVisible() + { + StaTestRunner.Run(() => + { + // Arrange + GameModification patch = CreateModification("Patch", ModificationType.Patch); + GameModification addon = CreateModification("Addon", ModificationType.Addon); + ILauncherContentCatalogQueries catalogQueries = CreateCatalogQueries( + patches: new[] { patch }, + addons: new[] { addon }); + LauncherWindowListController controller = CreateController( + out _, + out _, + out _, + out MainWindowViewModel viewModel, + out _, + catalogQueries: catalogQueries); + + // Act + controller.RefreshTabs(); + + // Assert + viewModel.PatchesListSource.Should().ContainSingle(tile => + tile.ContainerModification.Name == patch.Name); + viewModel.AddonsListSource.Should().ContainSingle(tile => + tile.ContainerModification.Name == addon.Name); + }); + } + + [Fact] + public void AddImportedContentToListPlacesContentAtTheFrontOfTheMatchingSource() + { + StaTestRunner.Run(() => + { + // Arrange + LauncherWindowListController controller = CreateController( + out _, + out _, + out _, + out MainWindowViewModel viewModel); + GameModification importedMod = CreateModification("Imported Mod", ModificationType.Mod); + GameModification importedPatch = CreateModification("Imported Patch", ModificationType.Patch); + GameModification importedAddon = CreateModification("Imported Addon", ModificationType.Addon); + + // Act + controller.AddImportedContentToList(new LauncherManualImportResult( + LauncherManualImportKind.Modification, + importedMod)); + controller.AddImportedContentToList(new LauncherManualImportResult( + LauncherManualImportKind.Patch, + importedPatch)); + controller.AddImportedContentToList(new LauncherManualImportResult( + LauncherManualImportKind.Addon, + importedAddon)); + + // Assert + viewModel.ModsListSource[0].ContainerModification.Name.Should().Be(importedMod.Name); + viewModel.PatchesListSource[0].ContainerModification.Name.Should().Be(importedPatch.Name); + viewModel.AddonsListSource[0].ContainerModification.Name.Should().Be(importedAddon.Name); + }); + } + + [Fact] + public void MoveAndRemoveContentForwardToTheViewModel() + { + StaTestRunner.Run(() => + { + // Arrange + LauncherWindowListController controller = CreateController( + out _, + out _, + out _, + out MainWindowViewModel viewModel); + ModificationViewModel first = CreateTile("First", ModificationType.Mod); + ModificationViewModel second = CreateTile("Second", ModificationType.Mod); + viewModel.ModsListSource.Add(first); + viewModel.ModsListSource.Add(second); + + // Act + controller.MoveModInList(second, sourceIndex: 1, targetIndex: 0); + controller.RemoveContentFromList(second); + + // Assert + viewModel.ModsListSource.Should().ContainSingle().Which.Should().BeSameAs(first); + second.ContainerModification.IsSelected.Should().BeFalse(); + first.ContainerModification.NumberInList.Should().Be(0); + }); + } + + [Fact] + public void GuardedOperationsRejectNullContent() + { + StaTestRunner.Run(() => + { + // Arrange + LauncherWindowListController controller = CreateController( + out _, + out _, + out _); + + // Act + Action ensure = () => controller.EnsureContentSelected(null!); + Action remove = () => controller.RemoveContentFromList(null!); + + // Assert + ensure.Should().Throw().WithParameterName("modification"); + remove.Should().Throw().WithParameterName("modification"); + }); + } + + [Fact] + public void CancelAndLabelRefreshOperationsForwardToTheViewModel() + { + StaTestRunner.Run(() => + { + // Arrange + LauncherWindowListController controller = CreateController( + out _, + out _, + out _, + out MainWindowViewModel viewModel); + + // Act + controller.CancelAllAddonsDownloads(); + controller.RefreshAddonAndPatchTabLabels(); + + // Assert + viewModel.PatchesTabText.Should().Contain("Patches"); + viewModel.AddonsTabText.Should().Contain("Add-ons"); + }); + } + + [Fact] + public void AddRepositoryModificationToListAddsDownloadedModificationAndRestoresUi() + { + StaTestRunner.Run(() => + { + // Arrange + var mods = new List(); + ModificationVersion downloadedVersion = CreateVersion("Repository Mod", ModificationType.Mod); + ILauncherContentCatalogQueries catalogQueries = CreateCatalogQueries(mods: mods); + ILauncherContentCatalogCommands catalogCommands = Substitute.For(); + ILauncherContentCatalogLoader catalogLoader = Substitute.For(); + catalogLoader.DownloadModificationDataFromReposAsync( + downloadedVersion.Name, + Arg.Any()) + .Returns(Task.FromResult(downloadedVersion)); + catalogCommands + .When(commands => commands.AddModModification(downloadedVersion)) + .Do(_ => mods.Add(CreateModification(downloadedVersion.Name, ModificationType.Mod))); + LauncherWindowListController controller = CreateController( + out _, + out _, + out _, + out MainWindowViewModel viewModel, + out _, + catalogQueries: catalogQueries, + catalogCommands: catalogCommands, + catalogLoader: catalogLoader); + + // Act + controller.AddRepositoryModificationToListAsync(downloadedVersion.Name).GetAwaiter().GetResult(); + + // Assert + viewModel.MainControlsEnabled.Should().BeTrue(); + viewModel.ModsListSource.Should().ContainSingle(tile => + tile.ContainerModification.Name == downloadedVersion.Name); + catalogCommands.Received().AddModModification(downloadedVersion); + catalogLoader.Received().ReadPatchesAndAddonsForModAsync( + downloadedVersion, + Arg.Any()); + }); + } + + [Fact] + public void UpdateAddonsAndPatchesAsyncForwardsToCatalogLoader() + { + StaTestRunner.Run(() => + { + // Arrange + ILauncherContentCatalogLoader catalogLoader = Substitute.For(); + LauncherWindowListController controller = CreateController( + out _, + out _, + out _, + out _, + out _, + catalogLoader: catalogLoader); + var modification = new ModificationReposVersion + { + Name = "Parent Mod", + Version = "1.0", + ModificationType = ModificationType.Mod + }; + + // Act + controller.UpdateAddonsAndPatchesAsync(modification).GetAwaiter().GetResult(); + + // Assert + catalogLoader.Received().ReadPatchesAndAddonsForModAsync( + modification, + Arg.Any()); + }); + } + + private static LauncherWindowListController CreateController( + out ListBox modsList, + out ListBox patchesList, + out ListBox addonsList) + { + return CreateController(out modsList, out patchesList, out addonsList, out _); + } + + private static LauncherWindowListController CreateController( + out ListBox modsList, + out ListBox patchesList, + out ListBox addonsList, + out MainWindowViewModel viewModel) + { + return CreateController(out modsList, out patchesList, out addonsList, out viewModel, out _); + } + + private static LauncherWindowListController CreateController( + out ListBox modsList, + out ListBox patchesList, + out ListBox addonsList, + out MainWindowViewModel viewModel, + out Window owner, + ILauncherContentCatalogQueries? catalogQueries = null, + ILauncherContentCatalogCommands? catalogCommands = null, + ILauncherContentCatalogLoader? catalogLoader = null) + { + modsList = new ListBox { SelectionMode = SelectionMode.Multiple }; + patchesList = new ListBox { SelectionMode = SelectionMode.Multiple }; + addonsList = new ListBox { SelectionMode = SelectionMode.Multiple }; + owner = new Window(); + viewModel = CreateViewModel(catalogQueries, catalogCommands, catalogLoader); + LauncherRuntimeContext runtimeContext = CreateRuntimeContext(); + LauncherVisualThemeService visualThemeService = new( + runtimeContext, + Substitute.For(), + NullLogger.Instance); + + return new LauncherWindowListController( + owner, + viewModel, + visualThemeService, + modsList, + patchesList, + addonsList); + } + + private static MainWindowViewModel CreateViewModel( + ILauncherContentCatalogQueries? catalogQueries = null, + ILauncherContentCatalogCommands? catalogCommands = null, + ILauncherContentCatalogLoader? catalogLoader = null) + { + ILauncherContentCatalogQueries resolvedCatalogQueries = catalogQueries ?? CreateCatalogQueries(); + ILauncherContentCatalogCommands resolvedCatalogCommands = + catalogCommands ?? Substitute.For(); + IGameExecutableDiscoveryService executableDiscovery = Substitute.For(); + LauncherRuntimeContext runtimeContext = CreateRuntimeContext(); + TestStringLocalizer stringLocalizer = CreateStringLocalizer(); + + return new MainWindowViewModel( + Substitute.For(), + new LauncherContentListService(resolvedCatalogQueries, resolvedCatalogCommands, runtimeContext), + new LauncherContentViewStateService(resolvedCatalogQueries, runtimeContext), + new LauncherTabStateService(resolvedCatalogQueries, runtimeContext, stringLocalizer), + new LauncherExecutableSelectionService(executableDiscovery, runtimeContext, stringLocalizer), + new LauncherSelectedContentService(resolvedCatalogQueries), + catalogLoader ?? Substitute.For(), + resolvedCatalogQueries, + resolvedCatalogCommands, + runtimeContext, + runtimeContext, + stringLocalizer, + new ModificationViewModelFactory( + new ModificationImageSourceFactory(NullLogger.Instance), + runtimeContext, + Substitute.For(), + stringLocalizer, + NullLogger.Instance), + new LauncherPackageActivityService()); + } + + private static ILauncherContentCatalogQueries CreateCatalogQueries( + IReadOnlyList? mods = null, + IReadOnlyList? patches = null, + IReadOnlyList? addons = null, + GameModification? selectedMod = null, + GameModification? selectedPatch = null, + IReadOnlyList? selectedAddons = null) + { + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetMods().Returns(_ => mods ?? Array.Empty()); + catalogQueries.GetPatchesForSelectedMod().Returns(_ => patches ?? Array.Empty()); + catalogQueries.GetAddonsForSelectedMod().Returns(_ => addons ?? Array.Empty()); + catalogQueries.GetSelectedMod().Returns(_ => selectedMod); + catalogQueries.GetSelectedPatch().Returns(_ => selectedPatch); + catalogQueries.GetSelectedAddonsForSelectedMod() + .Returns(_ => selectedAddons ?? Array.Empty()); + catalogQueries.GetSelectedAddonsVersions().Returns(Array.Empty()); + return catalogQueries; + } + + private static ModificationViewModel CreateTile(string name, ModificationType modificationType) + { + return new ModificationViewModel( + CreateModification(name, modificationType), + new ModificationImageSourceFactory(NullLogger.Instance), + new TestLauncherModsContext(colors: CreateColors()), + Substitute.For(), + CreateStringLocalizer(), + NullLogger.Instance); + } + + private static ModificationVersion CreateVersion(string name, ModificationType modificationType) + { + return new ModificationVersion + { + Name = name, + Version = "1.0", + Installed = true, + IsSelected = true, + ModificationType = modificationType + }; + } + + private static GameModification CreateModification(string name, ModificationType modificationType) + { + ModificationVersion version = CreateVersion(name, modificationType); + return new GameModification(version) + { + Name = name, + ModificationType = modificationType, + ModificationVersions = new List { version } + }; + } + + private static LauncherRuntimeContext CreateRuntimeContext() + { + ColorsInfo colors = CreateColors(); + LauncherRuntimeContext runtimeContext = new(CreatePaths(), "1.0") + { + DefaultColors = colors, + Colors = colors + }; + runtimeContext.SessionInformation.CurrentlyManagedGame = SupportedGame.ZeroHour; + return runtimeContext; + } + + private static TestStringLocalizer CreateStringLocalizer() + { + return new TestStringLocalizer(new Dictionary + { + ["Addons"] = "Add-ons for ", + ["AddAddonFromFiles"] = "Add add-on for {0}", + ["AddPatchFromFiles"] = "Add patch for {0}", + ["CurrentVersion"] = "Current version: ", + ["LatestVersion"] = "Latest version: ", + ["NoSupportedClient"] = "No supported client", + ["NoWorldBuildersFound"] = "No World Builders Found", + ["Patches"] = "Patches for ", + ["Update"] = "Update", + ["UpToDate"] = "Up to date", + }); + } + + private static ColorsInfo CreateColors() + { + return new ColorsInfo( + "#00E3FF", + "DarkGray", + "#7A7DB0", + "#BAFF0C", + "#232977", + "#090502", + "#B3000000", + "White", + "Black", + "#F21D2057", + "#E61D2057", + "#2534FF"); + } + + private static LauncherPaths CreatePaths() + { + return new LauncherPaths( + @"C:\Games\ZeroHour", + @"C:\Games\ZeroHour\GenLauncherGO", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache\Images", + @"C:\Games\ZeroHour\GenLauncherGO\Mods", + @"C:\Games\ZeroHour\GenLauncherGO\Logs", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Temp", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Deployment"); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/ViewModels/MainWindowActionEventArgsTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/ViewModels/MainWindowActionEventArgsTests.cs new file mode 100644 index 00000000..c933e56e --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/ViewModels/MainWindowActionEventArgsTests.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Launcher.ViewModels; +using GenLauncherGO.UI.Features.Mods; +using GenLauncherGO.UI.Features.Mods.Contracts; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.ViewModels; + +public sealed class MainWindowActionEventArgsTests +{ + [Fact] + public void ModificationActionRequestedEventArgsStoresKindAndModification() + { + // Arrange + ModificationViewModel modification = CreateViewModel(); + + // Act + MainWindowModificationActionRequestedEventArgs args = new( + MainWindowModificationActionKind.Update, + modification); + + // Assert + args.Kind.Should().Be(MainWindowModificationActionKind.Update); + args.Modification.Should().BeSameAs(modification); + } + + [Fact] + public void ModificationActionRequestedEventArgsThrowsForMissingModification() + { + // Arrange + Action act = () => new MainWindowModificationActionRequestedEventArgs( + MainWindowModificationActionKind.Update, + null!); + + // Act and Assert + act.Should().Throw(); + } + + [Fact] + public void VersionActionRequestedEventArgsStoresVersionSelection() + { + // Arrange + ModificationVersionSelection selection = new() + { + VersionName = "1.0" + }; + + // Act + MainWindowVersionActionRequestedEventArgs args = new(selection); + + // Assert + args.VersionSelection.Should().BeSameAs(selection); + } + + [Fact] + public void VersionActionRequestedEventArgsThrowsForMissingVersionSelection() + { + // Arrange + Action act = () => new MainWindowVersionActionRequestedEventArgs(null!); + + // Act and Assert + act.Should().Throw(); + } + + private static ModificationViewModel CreateViewModel() + { + return new ModificationViewModel( + new GameModification + { + Name = "ShockWave", + ModificationType = ModificationType.Mod, + ModificationVersions = new List + { + new ModificationVersion + { + Name = "ShockWave", + Version = "1.0", + ModificationType = ModificationType.Mod + } + } + }, + new ModificationImageSourceFactory(NullLogger.Instance), + new TestLauncherModsContext(), + Substitute.For(), + new TestStringLocalizer(), + NullLogger.Instance); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Launcher/ViewModels/MainWindowViewModelTests.cs b/GenLauncherGO.Tests/UI/Features/Launcher/ViewModels/MainWindowViewModelTests.cs new file mode 100644 index 00000000..db5e64f3 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Launcher/ViewModels/MainWindowViewModelTests.cs @@ -0,0 +1,1038 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Windows.Shell; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Settings.Contracts; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Launcher.Models; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Launcher.ViewModels; +using GenLauncherGO.UI.Features.Mods; +using GenLauncherGO.UI.Features.Mods.Contracts; +using GenLauncherGO.UI.Features.Startup; +using GenLauncherGO.UI.Shared.Themes; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Launcher.ViewModels; + +public sealed class MainWindowViewModelTests +{ + [Fact] + public void SetMainControlsEnabled_WhenDisabled_UpdatesComputedControlState() + { + // Arrange + MainWindowViewModel viewModel = CreateViewModel(); + + // Act + viewModel.SetMainControlsEnabled(false); + + // Assert + viewModel.MainControlsEnabled.Should().BeFalse(); + viewModel.StartGameButtonEnabled.Should().BeFalse(); + viewModel.WorldBuilderButtonEnabled.Should().BeFalse(); + viewModel.LoadingIndicatorVisibility.Should().Be(System.Windows.Visibility.Visible); + } + + [Fact] + public void SetMainControlsEnabled_WhenStateDoesNotChange_LeavesComputedControlState() + { + // Arrange + MainWindowViewModel viewModel = CreateViewModel(); + + // Act + viewModel.SetMainControlsEnabled(true); + + // Assert + viewModel.MainControlsEnabled.Should().BeTrue(); + viewModel.LoadingIndicatorVisibility.Should().Be(System.Windows.Visibility.Hidden); + } + + [Fact] + public void ConstructorExposesInitializedBindableProperties() + { + // Arrange + MainWindowViewModel viewModel = CreateViewModel(); + System.Windows.Visibility[] knownVisibilities = Enum.GetValues(); + + // Act and Assert + viewModel.QuickStartButtonText.Should().BeEmpty(); + viewModel.ManualAddPatchText.Should().BeEmpty(); + viewModel.ManualAddAddonText.Should().BeEmpty(); + knownVisibilities.Should().Contain(viewModel.AddModButtonVisibility); + knownVisibilities.Should().Contain(viewModel.ManualAddModVisibility); + knownVisibilities.Should().Contain(viewModel.ManualAddAddonVisibility); + knownVisibilities.Should().Contain(viewModel.PatchesButtonVisibility); + knownVisibilities.Should().Contain(viewModel.AddonsButtonVisibility); + viewModel.AddModButtonBlinking.Should().BeFalse(); + viewModel.TaskbarProgressState.Should().Be(TaskbarItemProgressState.None); + viewModel.TaskbarProgressValue.Should().Be(0D); + } + + [Fact] + public void PackageActivityChanges_UpdateTaskbarProgressState() + { + // Arrange + var packageActivityService = new LauncherPackageActivityService(); + MainWindowViewModel viewModel = CreateViewModel(packageActivityService: packageActivityService); + + // Act + bool started = packageActivityService.TryBegin( + "Shockwave", + out LauncherPackageActivityService.LauncherPackageActivityLease? lease); + packageActivityService.ReportProgress(42); + + // Assert + started.Should().BeTrue(); + viewModel.TaskbarProgressState.Should().Be(TaskbarItemProgressState.Normal); + viewModel.TaskbarProgressValue.Should().Be(0.42D); + + // Act + lease?.Dispose(); + + // Assert + viewModel.TaskbarProgressState.Should().Be(TaskbarItemProgressState.None); + viewModel.TaskbarProgressValue.Should().Be(0D); + } + + [Fact] + public void PackageActivityChanges_WhenProgressIsUnknown_ShowIndeterminateTaskbarProgress() + { + // Arrange + var packageActivityService = new LauncherPackageActivityService(); + MainWindowViewModel viewModel = CreateViewModel(packageActivityService: packageActivityService); + + // Act + bool started = packageActivityService.TryBegin( + "Shockwave", + out LauncherPackageActivityService.LauncherPackageActivityLease? lease); + + // Assert + started.Should().BeTrue(); + viewModel.TaskbarProgressState.Should().Be(TaskbarItemProgressState.Indeterminate); + viewModel.TaskbarProgressValue.Should().Be(0D); + + lease?.Dispose(); + } + + [Fact] + public void DisposeUnsubscribesFromPreferenceChanges() + { + // Arrange + var preferencesService = new RecordingLauncherPreferencesService(new LauncherPreferences()); + MainWindowViewModel viewModel = CreateViewModel(preferencesService); + + // Act + viewModel.Dispose(); + preferencesService.Update(preferencesService.Current with + { + GameArguments = LauncherGameArgumentService.WindowedArgument + }); + + // Assert + viewModel.WindowedModeButtonText.Should().BeEmpty(); + } + + [Fact] + public void ToggleGameArgument_WhenWindowedMissing_AddsArgumentAndRefreshesButtonText() + { + // Arrange + var preferencesService = new RecordingLauncherPreferencesService(new LauncherPreferences()); + MainWindowViewModel viewModel = CreateViewModel(preferencesService); + + // Act + viewModel.ToggleGameArgument(LauncherGameArgumentService.WindowedArgument); + + // Assert + preferencesService.Updates.Should().ContainSingle(); + preferencesService.Current.GameArguments.Should().Be(LauncherGameArgumentService.WindowedArgument); + viewModel.WindowedModeButtonText.Should().Be("Change to full screen"); + } + + [Fact] + public void SelectedGameClientOption_WhenSet_UpdatesPreferencesAndWindowTitle() + { + // Arrange + var preferencesService = new RecordingLauncherPreferencesService(new LauncherPreferences()); + MainWindowViewModel viewModel = CreateViewModel(preferencesService); + GameClientOption option = new("GenLauncherGO client", "generals.exe", GameClientExecutableKind.Community); + + // Act + viewModel.SelectedGameClientOption = option; + + // Assert + preferencesService.Current.SelectedGameClient.Should().Be("generals.exe"); + viewModel.WindowTitle.Should().Be("GenLauncherGO - Zero Hour - GenLauncherGO client"); + } + + [Fact] + public void SelectedWorldBuilderOption_WhenUnavailable_DoesNotUpdatePreferences() + { + // Arrange + var preferencesService = new RecordingLauncherPreferencesService(new LauncherPreferences()); + MainWindowViewModel viewModel = CreateViewModel(preferencesService); + + // Act + viewModel.SelectedWorldBuilderOption = new ExecutableOption( + "No World Builder", + string.Empty, + null, + isAvailable: false); + + // Assert + preferencesService.Updates.Should().BeEmpty(); + preferencesService.Current.SelectedWorldBuilder.Should().BeEmpty(); + } + + [Fact] + public void SelectedWorldBuilderOption_WhenAvailable_UpdatesPreferences() + { + // Arrange + var preferencesService = new RecordingLauncherPreferencesService(new LauncherPreferences()); + MainWindowViewModel viewModel = CreateViewModel(preferencesService); + + // Act + viewModel.SelectedWorldBuilderOption = new ExecutableOption( + "World Builder", + "worldbuilder.exe", + WorldBuilderExecutableKind.Community, + isAvailable: true); + + // Assert + preferencesService.Current.SelectedWorldBuilder.Should().Be("worldbuilder.exe"); + } + + [Fact] + public void InitializeRefreshesLauncherStateAndIncrementsLaunchCount() + { + // Arrange + var preferencesService = new RecordingLauncherPreferencesService(new LauncherPreferences + { + LaunchesCount = -1 + }); + IGameExecutableDiscoveryService executableDiscovery = Substitute.For(); + executableDiscovery.GetAvailableGameClients(SupportedGame.ZeroHour).Returns(new[] + { + new GameClientExecutable("generals.exe", GameClientExecutableKind.GeneralsOnline) + }); + executableDiscovery.GetAvailableWorldBuilders(SupportedGame.ZeroHour).Returns(new[] + { + new WorldBuilderExecutable("worldbuilder.exe", WorldBuilderExecutableKind.Community) + }); + MainWindowViewModel viewModel = CreateViewModel( + preferencesService, + mods: new[] { CreateModification("Shockwave", ModificationType.Mod) }, + executableDiscovery: executableDiscovery); + + // Act + viewModel.Initialize(); + + // Assert + viewModel.SupportedGameClients.Should().ContainSingle(); + viewModel.SupportedWorldBuilders.Should().ContainSingle(); + viewModel.ModsListSource.Should().ContainSingle(); + viewModel.CurrentLauncherVersionText.Should().Be("Current version: 1.2.3"); + preferencesService.Current.LaunchesCount.Should().Be(101); + } + + [Fact] + public void Initialize_WhenLaunchCountExceedsAdvertisingThreshold_DeletesAdvertisingAndResetsCounter() + { + // Arrange + var preferencesService = new RecordingLauncherPreferencesService(new LauncherPreferences + { + LaunchesCount = 101 + }); + ModificationVersion advertising = new() + { + Name = "Featured", + Version = "1.0", + ModificationType = ModificationType.Advertising + }; + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetMods().Returns(Array.Empty()); + catalogQueries.GetPatchesForSelectedMod().Returns(Array.Empty()); + catalogQueries.GetAddonsForSelectedMod().Returns(Array.Empty()); + catalogQueries.GetSelectedAddonsForSelectedMod().Returns(Array.Empty()); + catalogQueries.GetSelectedAddonsVersions().Returns(Array.Empty()); + catalogQueries.GetAdvertising().Returns(advertising); + ILauncherContentCatalogCommands catalogCommands = Substitute.For(); + MainWindowViewModel viewModel = CreateViewModel( + preferencesService, + catalogQueries: catalogQueries, + catalogCommands: catalogCommands); + + // Act + viewModel.Initialize(); + + // Assert + preferencesService.Current.LaunchesCount.Should().Be(1); + catalogCommands.Received(1).DeleteModificationVersion(advertising); + } + + [Fact] + public void RefreshGameClientOptions_WhenClientsExist_SelectsPreferredClientAndEnablesControls() + { + // Arrange + var preferencesService = new RecordingLauncherPreferencesService(new LauncherPreferences + { + SelectedGameClient = "genlauncher.exe" + }); + IGameExecutableDiscoveryService executableDiscovery = Substitute.For(); + executableDiscovery.GetAvailableGameClients(SupportedGame.ZeroHour).Returns(new[] + { + new GameClientExecutable("generals.exe", GameClientExecutableKind.GeneralsOnline), + new GameClientExecutable("genlauncher.exe", GameClientExecutableKind.Community) + }); + MainWindowViewModel viewModel = CreateViewModel( + preferencesService, + executableDiscovery: executableDiscovery); + + // Act + viewModel.RefreshGameClientOptions(); + + // Assert + viewModel.SupportedGameClients.Select(option => option.ExecutableName) + .Should() + .Equal("generals.exe", "genlauncher.exe"); + viewModel.SelectedGameClientOption!.ExecutableName.Should().Be("genlauncher.exe"); + viewModel.GameClientSelectorEnabled.Should().BeTrue(); + viewModel.StartGameButtonEnabled.Should().BeTrue(); + } + + [Fact] + public void RefreshWorldBuilderOptions_WhenNoneExist_SelectsUnavailablePlaceholderAndDisablesControls() + { + // Arrange + IGameExecutableDiscoveryService executableDiscovery = Substitute.For(); + executableDiscovery.GetAvailableWorldBuilders(SupportedGame.ZeroHour) + .Returns(Array.Empty()); + MainWindowViewModel viewModel = CreateViewModel(executableDiscovery: executableDiscovery); + + // Act + viewModel.RefreshWorldBuilderOptions(); + + // Assert + viewModel.SupportedWorldBuilders.Should().ContainSingle(); + viewModel.SelectedWorldBuilderOption!.DisplayName.Should().Be("No World Builders Found"); + viewModel.SelectedWorldBuilderOption.IsAvailable.Should().BeFalse(); + viewModel.WorldBuilderSelectorEnabled.Should().BeFalse(); + viewModel.WorldBuilderButtonEnabled.Should().BeFalse(); + } + + [Fact] + public void CloseCommand_WhenExecuted_RaisesCloseRequest() + { + // Arrange + MainWindowViewModel viewModel = CreateViewModel(); + bool closeRequested = false; + viewModel.CloseRequested += (_, _) => closeRequested = true; + + // Act + viewModel.CloseCommand.Execute(null); + + // Assert + closeRequested.Should().BeTrue(); + } + + [Fact] + public void LaunchGameCommand_WhenExecuted_RaisesLaunchGameRequest() + { + // Arrange + MainWindowViewModel viewModel = CreateViewModel(); + bool launchRequested = false; + viewModel.LaunchGameRequested += (_, _) => launchRequested = true; + + // Act + viewModel.LaunchGameCommand.Execute(null); + + // Assert + launchRequested.Should().BeTrue(); + } + + [Fact] + public void OtherWindowCommands_WhenExecuted_RaiseMatchingRequests() + { + // Arrange + MainWindowViewModel viewModel = CreateViewModel(); + bool worldBuilderRequested = false; + bool optionsRequested = false; + bool repositoryAddRequested = false; + viewModel.LaunchWorldBuilderRequested += (_, _) => worldBuilderRequested = true; + viewModel.OpenOptionsRequested += (_, _) => optionsRequested = true; + viewModel.AddRepositoryModificationRequested += (_, _) => repositoryAddRequested = true; + + // Act + viewModel.LaunchWorldBuilderCommand.Execute(null); + viewModel.OpenOptionsCommand.Execute(null); + viewModel.AddRepositoryModificationCommand.Execute(null); + + // Assert + worldBuilderRequested.Should().BeTrue(); + optionsRequested.Should().BeTrue(); + repositoryAddRequested.Should().BeTrue(); + } + + [Fact] + public void ImportManualPatchCommand_WhenExecuted_RaisesPatchImportRequest() + { + // Arrange + MainWindowViewModel viewModel = CreateViewModel(); + LauncherManualImportKind? requestedKind = null; + viewModel.ManualImportRequested += (_, args) => requestedKind = args.Kind; + + // Act + viewModel.ImportManualPatchCommand.Execute(null); + + // Assert + requestedKind.Should().Be(LauncherManualImportKind.Patch); + } + + [Fact] + public void ModificationActionCommand_WhenParameterIsTile_RaisesRequestedAction() + { + // Arrange + MainWindowViewModel viewModel = CreateViewModel( + mods: new[] { CreateModification("Shockwave", ModificationType.Mod) }); + viewModel.RefreshModsList(); + MainWindowModificationActionKind? requestedKind = null; + ModificationViewModel? requestedModification = null; + viewModel.ModificationActionRequested += (_, args) => + { + requestedKind = args.Kind; + requestedModification = args.Modification; + }; + + // Act + viewModel.OpenSupportCommand.Execute(viewModel.ModsListSource[0]); + + // Assert + requestedKind.Should().Be(MainWindowModificationActionKind.Support); + requestedModification.Should().BeSameAs(viewModel.ModsListSource[0]); + } + + [Fact] + public void UpdateAddonAndPatchTabLabels_WhenChildDownloadsAreActive_UpdatesTabDownloadIndicators() + { + // Arrange + MainWindowViewModel viewModel = CreateViewModel( + patches: new[] { CreateModification("Patch", ModificationType.Patch) }, + addons: new[] + { + CreateModification("Addon One", ModificationType.Addon), + CreateModification("Addon Two", ModificationType.Addon) + }); + + viewModel.RefreshPatchesList(); + viewModel.RefreshAddonsList(); + + viewModel.PatchesListSource[0].SetDownloader(Substitute.For()); + viewModel.PatchesListSource[0].SetUIMessages("Downloading", 45); + viewModel.AddonsListSource[0].SetDownloader(Substitute.For()); + viewModel.AddonsListSource[0].SetUIMessages("Downloading", 20); + viewModel.AddonsListSource[1].SetDownloader(Substitute.For()); + viewModel.AddonsListSource[1].SetUIMessages("Downloading", 60); + + // Act + viewModel.UpdateAddonAndPatchTabLabels(); + + // Assert + viewModel.PatchesTabDownloadIndicatorVisibility.Should().Be(System.Windows.Visibility.Visible); + viewModel.PatchesTabDownloadProgressValue.Should().Be(45); + viewModel.AddonsTabDownloadIndicatorVisibility.Should().Be(System.Windows.Visibility.Visible); + viewModel.AddonsTabDownloadProgressValue.Should().Be(40); + } + + [Fact] + public void UpdateCurrentLauncherVersionTextUsesRuntimeVersion() + { + // Arrange + MainWindowViewModel viewModel = CreateViewModel(); + + // Act + viewModel.UpdateCurrentLauncherVersionText(); + + // Assert + viewModel.CurrentLauncherVersionText.Should().Be("Current version: 1.2.3"); + } + + [Fact] + public async Task ShowContentViewAsync_WhenOriginalGameContentIsRequired_LoadsContentAndShowsPatchViewAsync() + { + // Arrange + ILauncherContentCatalogLoader catalogLoader = Substitute.For(); + catalogLoader.ReadOriginalGameAddonsAndPatchesAsync(Arg.Any()) + .Returns(Task.CompletedTask); + MainWindowViewModel viewModel = CreateViewModel(catalogLoader: catalogLoader); + + // Act + await viewModel.ShowContentViewAsync(LauncherContentViewKind.Patches); + + // Assert + await catalogLoader.Received(1).ReadOriginalGameAddonsAndPatchesAsync( + Arg.Any()); + viewModel.MainControlsEnabled.Should().BeTrue(); + viewModel.ModsListVisibility.Should().Be(System.Windows.Visibility.Hidden); + viewModel.PatchesListVisibility.Should().Be(System.Windows.Visibility.Visible); + viewModel.AddonsListVisibility.Should().Be(System.Windows.Visibility.Hidden); + viewModel.ManualAddPatchVisibility.Should().Be(System.Windows.Visibility.Visible); + } + + [Fact] + public void UpdateAddonAndPatchTabLabels_WhenChildIntegrityRepairIsActive_UpdatesTabDownloadIndicators() + { + // Arrange + MainWindowViewModel viewModel = CreateViewModel( + patches: new[] { CreateModification("Patch", ModificationType.Patch) }); + + viewModel.RefreshPatchesList(); + + // Act + viewModel.PatchesListSource[0].BeginIntegrityProgress("Preparing"); + + // Assert + viewModel.PatchesTabDownloadIndicatorVisibility.Should().Be(System.Windows.Visibility.Visible); + viewModel.PatchesTabDownloadProgressValue.Should().Be(0); + } + + [Fact] + public void ChildPackageActivity_WhenParentModIsDisplayed_ForwardsProgressToParentModTile() + { + // Arrange + const string parentName = "Shockwave"; + MainWindowViewModel viewModel = CreateViewModel( + mods: new[] { CreateModification(parentName, ModificationType.Mod) }, + patches: new[] { CreateModification("Patch", ModificationType.Patch, parentName) }); + + viewModel.RefreshModsList(); + viewModel.RefreshPatchesList(); + + // Act + viewModel.PatchesListSource[0].BeginIntegrityProgress("Repairing patch"); + viewModel.PatchesListSource[0].ReportIntegrityProgress("Repairing patch", 35); + + // Assert + viewModel.ModsListSource[0].HasActivePackageActivity.Should().BeTrue(); + viewModel.ModsListSource[0].ProgressMessage.Should().Be("Repairing patch"); + viewModel.ModsListSource[0].ProgressValue.Should().Be(35); + } + + [Fact] + public void ChildPackageActivity_WhenCompleted_ClearsForwardedParentModProgress() + { + // Arrange + const string parentName = "Shockwave"; + MainWindowViewModel viewModel = CreateViewModel( + mods: new[] { CreateModification(parentName, ModificationType.Mod) }, + patches: new[] { CreateModification("Patch", ModificationType.Patch, parentName) }); + + viewModel.RefreshModsList(); + viewModel.RefreshPatchesList(); + viewModel.PatchesListSource[0].BeginIntegrityProgress("Repairing patch"); + + // Act + viewModel.PatchesListSource[0].CompleteIntegrityProgress(); + + // Assert + viewModel.ModsListSource[0].HasActivePackageActivity.Should().BeFalse(); + viewModel.ModsListSource[0].ProgressValue.Should().Be(0); + } + + [Fact] + public void RefreshPatchesList_WhenActiveChildActivityExists_ReusesTileAndKeepsTabProgress() + { + // Arrange + const string parentName = "Shockwave"; + MainWindowViewModel viewModel = CreateViewModel( + patches: new[] { CreateModification("Patch", ModificationType.Patch, parentName) }); + + viewModel.RefreshPatchesList(); + ModificationViewModel activePatch = viewModel.PatchesListSource[0]; + activePatch.BeginIntegrityProgress("Repairing patch"); + activePatch.ReportIntegrityProgress("Repairing patch", 55); + + // Act + viewModel.RefreshPatchesList(); + + // Assert + viewModel.PatchesListSource[0].Should().BeSameAs(activePatch); + viewModel.PatchesListSource[0].HasActivePackageActivity.Should().BeTrue(); + viewModel.PatchesListSource[0].ProgressValue.Should().Be(55); + viewModel.PatchesTabDownloadIndicatorVisibility.Should().Be(System.Windows.Visibility.Visible); + viewModel.PatchesTabDownloadProgressValue.Should().Be(55); + } + + [Fact] + public void RefreshAddonsList_WhenActiveDownloadExists_ReusesTileAndKeepsVisibleProgress() + { + // Arrange + const string parentName = "Shockwave"; + MainWindowViewModel viewModel = CreateViewModel( + addons: new[] { CreateModification("Addon", ModificationType.Addon, parentName) }); + + viewModel.RefreshAddonsList(); + ModificationViewModel activeAddon = viewModel.AddonsListSource[0]; + activeAddon.SetDownloader(Substitute.For()); + activeAddon.SetUIMessages("Downloading", 72); + + // Act + viewModel.RefreshAddonsList(); + + // Assert + viewModel.AddonsListSource[0].Should().BeSameAs(activeAddon); + viewModel.AddonsListSource[0].HasActivePackageActivity.Should().BeTrue(); + viewModel.AddonsListSource[0].ProgressValue.Should().Be(72); + viewModel.AddonsTabDownloadIndicatorVisibility.Should().Be(System.Windows.Visibility.Visible); + viewModel.AddonsTabDownloadProgressValue.Should().Be(72); + } + + [Fact] + public void CancelAllAddonsDownloadsCancelsActivePatchAndAddonDownloads() + { + // Arrange + const string parentName = "Shockwave"; + MainWindowViewModel viewModel = CreateViewModel( + patches: new[] { CreateModification("Patch", ModificationType.Patch, parentName) }, + addons: new[] { CreateModification("Addon", ModificationType.Addon, parentName) }); + viewModel.RefreshPatchesList(); + viewModel.RefreshAddonsList(); + IPackageDownloadOperation patchDownloader = Substitute.For(); + IPackageDownloadOperation addonDownloader = Substitute.For(); + viewModel.PatchesListSource[0].SetDownloader(patchDownloader); + viewModel.AddonsListSource[0].SetDownloader(addonDownloader); + + // Act + viewModel.CancelAllAddonsDownloads(); + + // Assert + patchDownloader.Received(1).CancelDownload(); + addonDownloader.Received(1).CancelDownload(); + } + + [Fact] + public void BruteCancelAllDownloadsDisposesDisplayedDownloads() + { + // Arrange + const string parentName = "Shockwave"; + MainWindowViewModel viewModel = CreateViewModel( + mods: new[] { CreateModification(parentName, ModificationType.Mod) }, + patches: new[] { CreateModification("Patch", ModificationType.Patch, parentName) }, + addons: new[] { CreateModification("Addon", ModificationType.Addon, parentName) }); + viewModel.RefreshModsList(); + viewModel.RefreshPatchesList(); + viewModel.RefreshAddonsList(); + IPackageDownloadOperation modDownloader = Substitute.For(); + IPackageDownloadOperation patchDownloader = Substitute.For(); + IPackageDownloadOperation addonDownloader = Substitute.For(); + viewModel.ModsListSource[0].SetDownloader(modDownloader); + viewModel.PatchesListSource[0].SetDownloader(patchDownloader); + viewModel.AddonsListSource[0].SetDownloader(addonDownloader); + + // Act + viewModel.BruteCancelAllDownloads(); + + // Assert + modDownloader.Received(1).Dispose(); + patchDownloader.Received(1).Dispose(); + addonDownloader.Received(1).Dispose(); + } + + [Fact] + public async Task AddModToListAsync_DownloadsRepositoryDataAddsTileAndMovesItToTopAsync() + { + // Arrange + ModificationVersion downloadedVersion = new() + { + Name = "New Mod", + Version = "2.0", + ModificationType = ModificationType.Mod + }; + GameModification savedModification = CreateModification("New Mod", ModificationType.Mod); + ILauncherContentCatalogLoader catalogLoader = Substitute.For(); + catalogLoader.DownloadModificationDataFromReposAsync( + "New Mod", + Arg.Any()) + .Returns(Task.FromResult(downloadedVersion)); + catalogLoader.ReadPatchesAndAddonsForModAsync( + downloadedVersion, + Arg.Any()) + .Returns(Task.CompletedTask); + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetMods().Returns(new[] { savedModification }); + catalogQueries.GetPatchesForSelectedMod().Returns(Array.Empty()); + catalogQueries.GetAddonsForSelectedMod().Returns(Array.Empty()); + catalogQueries.GetSelectedAddonsForSelectedMod().Returns(Array.Empty()); + catalogQueries.GetSelectedAddonsVersions().Returns(Array.Empty()); + ILauncherContentCatalogCommands catalogCommands = Substitute.For(); + MainWindowViewModel viewModel = CreateViewModel( + catalogLoader: catalogLoader, + catalogQueries: catalogQueries, + catalogCommands: catalogCommands); + + // Act + await viewModel.AddModToListAsync("New Mod"); + + // Assert + catalogCommands.Received(1).AddModModification(downloadedVersion); + viewModel.ModsListSource.Should().ContainSingle(); + viewModel.ModsListSource[0].ContainerModification.Name.Should().Be("New Mod"); + viewModel.ModsListSource[0].ContainerModification.NumberInList.Should().Be(0); + } + + [Fact] + public void UpdateAddonAndPatchTabLabels_WhenChildDownloadsAreInactive_HidesTabDownloadIndicators() + { + // Arrange + MainWindowViewModel viewModel = CreateViewModel( + patches: new[] { CreateModification("Patch", ModificationType.Patch) }, + addons: new[] { CreateModification("Addon", ModificationType.Addon) }); + + viewModel.RefreshPatchesList(); + viewModel.RefreshAddonsList(); + + // Act + viewModel.UpdateAddonAndPatchTabLabels(); + + // Assert + viewModel.PatchesTabDownloadIndicatorVisibility.Should().Be(System.Windows.Visibility.Hidden); + viewModel.PatchesTabDownloadProgressValue.Should().Be(0); + viewModel.AddonsTabDownloadIndicatorVisibility.Should().Be(System.Windows.Visibility.Hidden); + viewModel.AddonsTabDownloadProgressValue.Should().Be(0); + } + + [Fact] + public void RemoveContentFromList_WhenRemovingModification_RemovesTileAndRenumbersRemainingMods() + { + // Arrange + MainWindowViewModel viewModel = CreateViewModel( + mods: new[] + { + CreateModification("First", ModificationType.Mod), + CreateModification("Second", ModificationType.Mod) + }); + viewModel.RefreshModsList(); + ModificationViewModel removed = viewModel.ModsListSource[0]; + + // Act + viewModel.RemoveContentFromList(removed); + + // Assert + viewModel.ModsListSource.Select(modification => modification.ContainerModification.Name) + .Should() + .Equal("Second"); + viewModel.ModsListSource[0].ContainerModification.NumberInList.Should().Be(0); + removed.ContainerModification.IsSelected.Should().BeFalse(); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + public void AddImportedContentToListAddsContentToMatchingList(int kindValue) + { + // Arrange + var kind = (LauncherManualImportKind)kindValue; + MainWindowViewModel viewModel = CreateViewModel(); + GameModification importedModification = CreateModification( + kind.ToString(), + kind switch + { + LauncherManualImportKind.Patch => ModificationType.Patch, + LauncherManualImportKind.Addon => ModificationType.Addon, + _ => ModificationType.Mod + }); + LauncherManualImportResult importResult = new(kind, importedModification); + + // Act + viewModel.AddImportedContentToList(importResult); + + // Assert + IReadOnlyList targetList = kind switch + { + LauncherManualImportKind.Patch => viewModel.PatchesListSource, + LauncherManualImportKind.Addon => viewModel.AddonsListSource, + _ => viewModel.ModsListSource + }; + targetList.Should().ContainSingle(); + targetList[0].ContainerModification.Name.Should().Be(importedModification.Name); + } + + [Fact] + public void AddImportedContentToListThrowsForUnknownImportKind() + { + // Arrange + MainWindowViewModel viewModel = CreateViewModel(); + var importResult = new LauncherManualImportResult( + (LauncherManualImportKind)999, + CreateModification("Unknown", ModificationType.Mod)); + + // Act + Action act = () => viewModel.AddImportedContentToList(importResult); + + // Assert + act.Should().Throw() + .WithParameterName("importResult"); + } + + [Fact] + public void RefreshModificationContainerDataRefreshesDisplayedModTiles() + { + // Arrange + MainWindowViewModel viewModel = CreateViewModel( + mods: new[] { CreateModification("Shockwave", ModificationType.Mod) }); + viewModel.RefreshModsList(); + ModificationViewModel tile = viewModel.ModsListSource[0]; + tile.ContainerModification.Name = "Shockwave Updated"; + + // Act + viewModel.RefreshModificationContainerData(); + + // Assert + tile.NameInfo.Should().Be("Shockwave Updated"); + } + + [Fact] + public void DeleteVersionCommandRaisesDeleteVersionRequestForVersionSelection() + { + // Arrange + MainWindowViewModel viewModel = CreateViewModel(); + ModificationVersionSelection selection = new() + { + SelectedVersion = new ModificationVersion + { + Name = "Shockwave", + Version = "1.0" + } + }; + MainWindowVersionActionRequestedEventArgs? raisedArgs = null; + viewModel.DeleteVersionRequested += (_, args) => raisedArgs = args; + + // Act + viewModel.DeleteVersionCommand.Execute(selection); + + // Assert + raisedArgs.Should().NotBeNull(); + raisedArgs!.VersionSelection.Should().BeSameAs(selection); + } + + [Fact] + public void GetSelectedModificationNameReturnsCatalogSelectionName() + { + // Arrange + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedMod().Returns(CreateModification("Shockwave", ModificationType.Mod)); + MainWindowViewModel viewModel = CreateViewModel(catalogQueries: catalogQueries); + + // Act + string? selectedName = viewModel.GetSelectedModificationName(); + + // Assert + selectedName.Should().Be("Shockwave"); + } + + [Fact] + public void SaveLauncherDataIfContentLoaded_SavesOnlyAfterModsAreLoaded() + { + // Arrange + ILauncherContentCatalogCommands catalogCommands = Substitute.For(); + MainWindowViewModel emptyViewModel = CreateViewModel(catalogCommands: catalogCommands); + MainWindowViewModel loadedViewModel = CreateViewModel( + catalogCommands: catalogCommands, + mods: new[] { CreateModification("Shockwave", ModificationType.Mod) }); + + // Act + emptyViewModel.SaveLauncherDataIfContentLoaded(); + loadedViewModel.RefreshModsList(); + loadedViewModel.SaveLauncherDataIfContentLoaded(); + + // Assert + catalogCommands.Received(1).SaveLauncherData(); + } + + [Theory] + [InlineData(ModificationType.Addon)] + [InlineData(ModificationType.Patch)] + public void RemoveContentFromList_WhenRemovingChildContent_RemovesTileFromMatchingList( + ModificationType modificationType) + { + // Arrange + MainWindowViewModel viewModel = modificationType == ModificationType.Addon + ? CreateViewModel(addons: new[] { CreateModification("Addon", ModificationType.Addon) }) + : CreateViewModel(patches: new[] { CreateModification("Patch", ModificationType.Patch) }); + if (modificationType == ModificationType.Addon) + { + viewModel.RefreshAddonsList(); + } + else + { + viewModel.RefreshPatchesList(); + } + + IReadOnlyList childList = modificationType == ModificationType.Addon + ? viewModel.AddonsListSource + : viewModel.PatchesListSource; + ModificationViewModel child = childList[0]; + child.ContainerModification.IsSelected = true; + + // Act + viewModel.RemoveContentFromList(child); + + // Assert + childList.Should().BeEmpty(); + child.ContainerModification.IsSelected.Should().BeFalse(); + } + + private static MainWindowViewModel CreateViewModel( + RecordingLauncherPreferencesService? preferencesService = null, + IReadOnlyList? mods = null, + IReadOnlyList? patches = null, + IReadOnlyList? addons = null, + ILauncherContentCatalogLoader? catalogLoader = null, + ILauncherContentCatalogQueries? catalogQueries = null, + ILauncherContentCatalogCommands? catalogCommands = null, + IGameExecutableDiscoveryService? executableDiscovery = null, + LauncherPackageActivityService? packageActivityService = null) + { + ILauncherContentCatalogQueries resolvedCatalogQueries = + catalogQueries ?? Substitute.For(); + if (catalogQueries == null) + { + resolvedCatalogQueries.GetMods().Returns(mods ?? Array.Empty()); + resolvedCatalogQueries.GetPatchesForSelectedMod().Returns(patches ?? Array.Empty()); + resolvedCatalogQueries.GetAddonsForSelectedMod().Returns(addons ?? Array.Empty()); + resolvedCatalogQueries.GetSelectedAddonsForSelectedMod().Returns(Array.Empty()); + resolvedCatalogQueries.GetSelectedAddonsVersions().Returns(Array.Empty()); + } + + ILauncherContentCatalogCommands resolvedCatalogCommands = + catalogCommands ?? Substitute.For(); + ILauncherContentCatalogLoader resolvedCatalogLoader = + catalogLoader ?? Substitute.For(); + IGameExecutableDiscoveryService resolvedExecutableDiscovery = + executableDiscovery ?? Substitute.For(); + var runtimeContext = new LauncherRuntimeContext(CreateLauncherPaths(), "1.2.3"); + runtimeContext.SessionInformation.CurrentlyManagedGame = SupportedGame.ZeroHour; + ColorsInfo colors = CreateColors(); + runtimeContext.DefaultColors = colors; + runtimeContext.Colors = colors; + var stringLocalizer = new TestStringLocalizer(new Dictionary + { + ["Addons"] = "Add-ons for ", + ["ChangeToFullScreen"] = "Change to full screen", + ["ChangeToNormalStart"] = "Change to normal start", + ["ChangeToQuickStart"] = "Change to quick start", + ["ChangeToWindowed"] = "Change to windowed", + ["CurrentVersion"] = "Current version: ", + ["GeneralsOnlineClient"] = "GeneralsOnline client", + ["NoSupportedClient"] = "No supported client", + ["NoWorldBuildersFound"] = "No World Builders Found", + ["Patches"] = "Patches for ", + ["Preparing"] = "Preparing", + ["SuperHackersClient"] = "SuperHackers client", + ["SuperHackersWorldBuilder"] = "SuperHackers World Builder", + ["VanillaWorldBuilder"] = "Vanilla World Builder", + }); + + return new MainWindowViewModel( + preferencesService ?? new RecordingLauncherPreferencesService(new LauncherPreferences()), + new LauncherContentListService(resolvedCatalogQueries, resolvedCatalogCommands, runtimeContext), + new LauncherContentViewStateService(resolvedCatalogQueries, runtimeContext), + new LauncherTabStateService(resolvedCatalogQueries, runtimeContext, stringLocalizer), + new LauncherExecutableSelectionService(resolvedExecutableDiscovery, runtimeContext, stringLocalizer), + new LauncherSelectedContentService(resolvedCatalogQueries), + resolvedCatalogLoader, + resolvedCatalogQueries, + resolvedCatalogCommands, + runtimeContext, + runtimeContext, + stringLocalizer, + new ModificationViewModelFactory( + new ModificationImageSourceFactory(NullLogger.Instance), + runtimeContext, + Substitute.For(), + stringLocalizer, + NullLogger.Instance), + packageActivityService ?? new LauncherPackageActivityService()); + } + + private static LauncherPaths CreateLauncherPaths() + { + return new LauncherPaths( + @"C:\Games\ZeroHour", + @"C:\Games\ZeroHour\GenLauncherGO", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache\Images", + @"C:\Games\ZeroHour\GenLauncherGO\Mods", + @"C:\Games\ZeroHour\GenLauncherGO\Logs", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Temp", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Deployment"); + } + + private static ColorsInfo CreateColors() + { + return new ColorsInfo( + "#00E3FF", + "DarkGray", + "#7A7DB0", + "#BAFF0C", + "#232977", + "#090502", + "#B3000000", + "White", + "Black", + "#F21D2057", + "#E61D2057", + "#2534FF"); + } + + private static GameModification CreateModification( + string name, + ModificationType modificationType, + string dependenceName = "") + { + var version = new ModificationVersion + { + Name = name, + Version = "1.0", + ModificationType = modificationType, + DependenceName = dependenceName + }; + + return new GameModification(version) + { + Name = name, + ModificationType = modificationType, + DependenceName = dependenceName + }; + } + + private sealed class RecordingLauncherPreferencesService : ILauncherPreferencesService + { + public RecordingLauncherPreferencesService(LauncherPreferences preferences) + { + Current = preferences; + } + + public event EventHandler? PreferencesChanged; + + public LauncherPreferences Current { get; private set; } + + public List Updates { get; } = new(); + + public void Update(LauncherPreferences preferences) + { + Current = preferences; + Updates.Add(preferences); + PreferencesChanged?.Invoke(this, new LauncherPreferencesChangedEventArgs(preferences)); + } + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Mods/ModificationImageSourceFactoryTests.cs b/GenLauncherGO.Tests/UI/Features/Mods/ModificationImageSourceFactoryTests.cs new file mode 100644 index 00000000..a66c20cc --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Mods/ModificationImageSourceFactoryTests.cs @@ -0,0 +1,128 @@ +using System; +using System.IO; +using System.Threading; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Mods; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Mods; + +public sealed class ModificationImageSourceFactoryTests +{ + [Theory] + [InlineData(SupportedGame.Generals)] + [InlineData(SupportedGame.ZeroHour)] + public void LoadDefaultImageReturnsFrozenResourceImage(SupportedGame supportedGame) + { + RunOnStaThread(() => + { + ModificationImageSourceFactory factory = CreateFactory(); + + BitmapSource image = factory.LoadDefaultImage(supportedGame, grayscale: false); + + image.IsFrozen.Should().BeTrue(); + image.PixelWidth.Should().BeGreaterThan(0); + image.PixelHeight.Should().BeGreaterThan(0); + }); + } + + [Fact] + public void LoadDefaultImageReturnsFrozenGrayscaleResourceImage() + { + RunOnStaThread(() => + { + ModificationImageSourceFactory factory = CreateFactory(); + + BitmapSource image = factory.LoadDefaultImage(SupportedGame.Generals, grayscale: true); + + image.IsFrozen.Should().BeTrue(); + image.Format.Should().Be(PixelFormats.Gray8); + }); + } + + [Fact] + public void LoadFileImageReadsSourceIntoMemory() + { + RunOnStaThread(() => + { + using TestDirectory testDirectory = new(); + string imagePath = Path.Combine(testDirectory.Path, "mod-image.png"); + SaveTestImage(imagePath); + ModificationImageSourceFactory factory = CreateFactory(); + + BitmapSource? image = factory.LoadFileImage(imagePath, grayscale: false); + File.Delete(imagePath); + + image.Should().NotBeNull(); + image!.IsFrozen.Should().BeTrue(); + image.PixelWidth.Should().Be(2); + image.PixelHeight.Should().Be(2); + File.Exists(imagePath).Should().BeFalse(); + }); + } + + [Fact] + public void LoadFileImageReturnsNullWhenPathIsMissing() + { + RunOnStaThread(() => + { + ModificationImageSourceFactory factory = CreateFactory(); + + BitmapSource? image = factory.LoadFileImage("missing-image.png", grayscale: false); + + image.Should().BeNull(); + }); + } + + private static ModificationImageSourceFactory CreateFactory() + { + return new ModificationImageSourceFactory(NullLogger.Instance); + } + + private static void SaveTestImage(string path) + { + DrawingVisual visual = new(); + using (DrawingContext drawingContext = visual.RenderOpen()) + { + drawingContext.DrawRectangle(Brushes.DarkRed, null, new Rect(0, 0, 2, 2)); + } + + RenderTargetBitmap bitmap = new(2, 2, 96, 96, PixelFormats.Pbgra32); + bitmap.Render(visual); + + PngBitmapEncoder encoder = new(); + encoder.Frames.Add(BitmapFrame.Create(bitmap)); + + using FileStream stream = File.Create(path); + encoder.Save(stream); + } + + private static void RunOnStaThread(Action action) + { + Exception? exception = null; + Thread thread = new(() => + { + try + { + action(); + } + catch (Exception caughtException) + { + exception = caughtException; + } + }); + + thread.SetApartmentState(ApartmentState.STA); + thread.Start(); + thread.Join(); + + if (exception is not null) + { + throw exception; + } + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Mods/ModificationViewModelTests.cs b/GenLauncherGO.Tests/UI/Features/Mods/ModificationViewModelTests.cs new file mode 100644 index 00000000..b42c72bb --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Mods/ModificationViewModelTests.cs @@ -0,0 +1,234 @@ +using System.Collections.Generic; +using System.Windows; +using System.Windows.Media; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Mods; +using GenLauncherGO.UI.Shared.Themes; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Mods; + +public sealed class ModificationViewModelTests +{ + [Fact] + public void Constructor_WhenLatestVersionInstalled_LabelsDisabledUpdateButtonAsUpToDate() + { + // Arrange + GameModification modification = CreateModification(installed: true); + TestStringLocalizer stringLocalizer = new(new Dictionary + { + ["LatestVersion"] = "Latest version: ", + ["Update"] = "Update!", + ["UpToDate"] = "Up-to-date" + }); + + // Act + ModificationViewModel viewModel = CreateViewModel(modification, stringLocalizer); + + // Assert + viewModel.UpdateButtonEnabled.Should().BeFalse(); + viewModel.UpdateButtonContent.Should().Be("Up-to-date"); + } + + [Fact] + public void Constructor_WhenSingleLatestVersionIsNotInstalled_LeavesInstallButtonText() + { + // Arrange + GameModification modification = CreateModification(installed: false); + TestStringLocalizer stringLocalizer = new(new Dictionary + { + ["LatestVersion"] = "Latest version: ", + ["Update"] = "Update!", + ["UpToDate"] = "Up-to-date" + }); + + // Act + ModificationViewModel viewModel = CreateViewModel(modification, stringLocalizer); + + // Assert + viewModel.UpdateButtonEnabled.Should().BeTrue(); + viewModel.UpdateButtonContent.Should().Be("Install"); + } + + [Fact] + public void PrepareControlsToDownloadModeLabelsUpdateButtonAsCancel() + { + // Arrange + ModificationViewModel viewModel = CreateViewModel( + CreateModification(installed: false), + new TestStringLocalizer(new Dictionary + { + ["LatestVersion"] = "Latest version: ", + ["Cancel"] = "Cancel" + })); + + // Act + viewModel.PrepareControlsToDownloadMode(); + + // Assert + viewModel.UpdateButtonContent.Should().Be("Cancel"); + } + + [Fact] + public void SetActiveProgressBarUsesThemeActiveColorForFill() + { + // Arrange + var fillColor = Color.FromRgb(186, 255, 12); + var backgroundColor = Color.FromRgb(37, 52, 255); + ModificationViewModel viewModel = CreateViewModel(CreateColors()); + + // Act + viewModel.SetActiveProgressBar(); + + // Assert + viewModel.ProgressForeground.Should().BeOfType() + .Which.Color.Should().Be(fillColor); + viewModel.ProgressBackground.Should().BeOfType() + .Which.Color.Should().Be(backgroundColor); + } + + [Fact] + public void ConstructorExposesInitializedBindableProperties() + { + // Arrange + ModificationViewModel viewModel = CreateViewModel( + CreateModification(installed: true), + new TestStringLocalizer(new Dictionary + { + ["LatestVersion"] = "Latest version: ", + ["Update"] = "Update!", + ["Support"] = "Support", + ["Changelog"] = "Changelog", + ["NetworkInfo"] = "Network" + })); + + // Act and Assert + viewModel.NameInfo.Should().Be("ShockWave"); + viewModel.LatestVersionInfo.Should().Be("Latest version: 1.0"); + viewModel.ActiveIntegrityVersion.Should().BeSameAs(viewModel.SelectedVersion); + viewModel.CanReportIntegrityProgress.Should().BeTrue(); + viewModel.SelectedVersionOption.Should().NotBeNull(); + viewModel.ImageSource.Should().BeNull(); + viewModel.NameForeground.Should().NotBeNull(); + viewModel.VersionForeground.Should().NotBeNull(); + viewModel.NameFontWeight.Should().Be(FontWeights.Normal); + viewModel.VersionSelectorVisibility.Should().Be(Visibility.Hidden); + viewModel.DragAndDropVisibility.Should().Be(Visibility.Hidden); + viewModel.UpdateRectangleVisibility.Should().Be(Visibility.Hidden); + viewModel.UpdateButtonVisibility.Should().Be(Visibility.Visible); + viewModel.SupportButtonVisibility.Should().Be(Visibility.Hidden); + viewModel.NetworkInfoVisibility.Should().Be(Visibility.Hidden); + viewModel.ChangeLogVisibility.Should().Be(Visibility.Hidden); + viewModel.ImageBorderThickness.Should().Be(new Thickness(0)); + viewModel.ImageBorderBrush.Should().NotBeNull(); + viewModel.ProgressBorderBrush.Should().NotBeNull(); + viewModel.ProgressTextForeground.Should().NotBeNull(); + viewModel.SupportButtonContent.Should().Be("Donate"); + viewModel.ChangeLogButtonContent.Should().Be("ChangelogOnly"); + viewModel.NetworkInfoButtonContent.Should().Be("PlayOnline"); + viewModel.UpdateButtonBlinking.Should().BeFalse(); + viewModel.SupportButtonBlinking.Should().BeFalse(); + viewModel.IsVersionSelectorEnabled.Should().BeTrue(); + } + + [Fact] + public void DragDropVisualStateCanBeAppliedAndCleared() + { + // Arrange + ModificationViewModel viewModel = CreateViewModel(CreateModification(installed: true), new TestStringLocalizer()); + + // Act + viewModel.SetDragAndDropMod(); + + // Assert + viewModel.DragAndDropVisibility.Should().Be(Visibility.Visible); + viewModel.NameFontWeight.Should().Be(FontWeights.Bold); + + // Act + viewModel.RemoveDragAndDropMod(); + + // Assert + viewModel.DragAndDropVisibility.Should().Be(Visibility.Hidden); + viewModel.NameFontWeight.Should().Be(FontWeights.Normal); + } + + [Fact] + public void BruteCancelDownloadDisposesActiveDownloader() + { + // Arrange + ModificationViewModel viewModel = CreateViewModel(CreateModification(installed: false), new TestStringLocalizer()); + IPackageDownloadOperation downloader = Substitute.For(); + viewModel.Downloader = downloader; + + // Act + viewModel.BruteCancelDownload(); + + // Assert + downloader.Received(1).Dispose(); + } + + private static ModificationViewModel CreateViewModel(ColorsInfo colors) + { + return CreateViewModel(CreateModification(installed: false), new TestStringLocalizer(), colors); + } + + private static ModificationViewModel CreateViewModel( + GameModification modification, + TestStringLocalizer stringLocalizer) + { + return CreateViewModel(modification, stringLocalizer, CreateColors()); + } + + private static ModificationViewModel CreateViewModel( + GameModification modification, + TestStringLocalizer stringLocalizer, + ColorsInfo colors) + { + return new ModificationViewModel( + modification, + new ModificationImageSourceFactory(NullLogger.Instance), + new TestLauncherModsContext(colors: colors), + Substitute.For(), + stringLocalizer, + NullLogger.Instance); + } + + private static GameModification CreateModification(bool installed) + { + return new GameModification + { + Name = "ShockWave", + ModificationType = ModificationType.Mod, + ModificationVersions = new List + { + new ModificationVersion + { + Name = "ShockWave", + Version = "1.0", + ModificationType = ModificationType.Mod, + Installed = installed + } + } + }; + } + + private static ColorsInfo CreateColors() + { + return new ColorsInfo( + "#00E3FF", + "DarkGray", + "#7A7DB0", + "#BAFF0C", + "#232977", + "#090502", + "#B3000000", + "White", + "Black", + "#F21D2057", + "#E61D2057", + "#2534FF"); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Mods/ModsUiServiceCollectionExtensionsTests.cs b/GenLauncherGO.Tests/UI/Features/Mods/ModsUiServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..d81cda30 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Mods/ModsUiServiceCollectionExtensionsTests.cs @@ -0,0 +1,37 @@ +using GenLauncherGO.UI.Features.Mods; +using GenLauncherGO.UI.Features.Mods.Composition; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Tests.UI.Features.Mods; + +public sealed class ModsUiServiceCollectionExtensionsTests +{ + [Fact] + public void AddGenLauncherGoModsUi_RegistersModificationImageSourceFactory() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoModsUi(); + using ServiceProvider provider = services.BuildServiceProvider(); + + // Assert + provider.GetRequiredService().Should().NotBeNull(); + } + + [Fact] + public void AddGenLauncherGoModsUi_RegistersModificationViewModelFactory() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoModsUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(ModificationViewModelFactory) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Mods/ViewModels/ModificationTileImageProviderTests.cs b/GenLauncherGO.Tests/UI/Features/Mods/ViewModels/ModificationTileImageProviderTests.cs new file mode 100644 index 00000000..9ba9cf2b --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Mods/ViewModels/ModificationTileImageProviderTests.cs @@ -0,0 +1,314 @@ +using System; +using System.IO; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Mods; +using GenLauncherGO.UI.Features.Mods.Contracts; +using GenLauncherGO.UI.Features.Mods.ViewModels; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.UI.Features.Mods.ViewModels; + +public sealed class ModificationTileImageProviderTests +{ + [Fact] + public void ConstructorThrowsForMissingDependencies() + { + // Arrange + ModificationImageSourceFactory imageSourceFactory = CreateImageSourceFactory(); + ILauncherModsContext launcherContext = Substitute.For(); + IModificationImageFileService imageFileService = Substitute.For(); + + // Act + Action missingFactory = () => new ModificationTileImageProvider( + null!, + launcherContext, + imageFileService, + NullLogger.Instance); + Action missingContext = () => new ModificationTileImageProvider( + imageSourceFactory, + null!, + imageFileService, + NullLogger.Instance); + Action missingFileService = () => new ModificationTileImageProvider( + imageSourceFactory, + launcherContext, + null!, + NullLogger.Instance); + Action missingLogger = () => new ModificationTileImageProvider( + imageSourceFactory, + launcherContext, + imageFileService, + null!); + + // Assert + missingFactory.Should().Throw(); + missingContext.Should().Throw(); + missingFileService.Should().Throw(); + missingLogger.Should().Throw(); + } + + [Fact] + public void LoadImagesReturnNullWhenNoWpfApplicationExists() + { + // Arrange + IModificationImageFileService imageFileService = Substitute.For(); + ModificationTileImageProvider provider = CreateProvider( + imageFileService, + canLoadImages: () => false); + GameModification modification = new(CreateVersion()); + ModificationVersion latestVersion = CreateVersion(); + + // Act + object? grayscaleImage = provider.LoadGrayscaleImage(modification, latestVersion, localMod: false); + object? colorImage = provider.LoadColorImage(modification, latestVersion, localMod: false); + + // Assert + grayscaleImage.Should().BeNull(); + colorImage.Should().BeNull(); + imageFileService.DidNotReceive().FindExistingImageFilePath(Arg.Any(), Arg.Any()); + } + + [Fact] + public void LoadGrayscaleImageForModLoadsCachedImageWhenPresent() + { + // Arrange + const string ImagePath = @"C:\Cache\ShockWave\1.0.png"; + IModificationImageFileService imageFileService = Substitute.For(); + imageFileService.FindExistingImageFilePath("ShockWave", "1.0").Returns(ImagePath); + imageFileService.ImageExists(ImagePath).Returns(true); + BitmapSource cachedImage = CreateImage(); + string? loadedPath = null; + bool? loadedGrayscale = null; + ModificationTileImageProvider provider = CreateProvider( + imageFileService, + loadFileImage: (path, grayscale) => + { + loadedPath = path; + loadedGrayscale = grayscale; + return cachedImage; + }); + + // Act + ImageSource? result = provider.LoadGrayscaleImage( + new GameModification(CreateVersion()), + CreateVersion(), + localMod: false); + + // Assert + result.Should().BeSameAs(cachedImage); + loadedPath.Should().Be(ImagePath); + loadedGrayscale.Should().BeTrue(); + } + + [Fact] + public void LoadColorImageForNonModReturnsNullWithoutImageLookup() + { + // Arrange + IModificationImageFileService imageFileService = Substitute.For(); + ModificationTileImageProvider provider = CreateProvider(imageFileService); + ModificationVersion version = CreateVersion(ModificationType.Patch); + GameModification modification = new(version) + { + ModificationType = ModificationType.Patch + }; + + // Act + ImageSource? result = provider.LoadColorImage(modification, version, localMod: false); + + // Assert + result.Should().BeNull(); + imageFileService.DidNotReceive().FindExistingImageFilePath(Arg.Any(), Arg.Any()); + } + + [Fact] + public void LoadGrayscaleImageForMissingModImageUsesDefaultImage() + { + // Arrange + IModificationImageFileService imageFileService = Substitute.For(); + imageFileService.FindExistingImageFilePath("ShockWave", "1.0").Returns((string?)null); + imageFileService.ImageExists(null).Returns(false); + BitmapSource defaultImage = CreateImage(); + SupportedGame? requestedGame = null; + bool? requestedGrayscale = null; + ModificationTileImageProvider provider = CreateProvider( + imageFileService, + loadDefaultImage: (game, grayscale) => + { + requestedGame = game; + requestedGrayscale = grayscale; + return defaultImage; + }); + + // Act + ImageSource? result = provider.LoadGrayscaleImage( + new GameModification(CreateVersion()), + CreateVersion(), + localMod: false); + + // Assert + result.Should().BeSameAs(defaultImage); + requestedGame.Should().Be(SupportedGame.ZeroHour); + requestedGrayscale.Should().BeTrue(); + } + + [Fact] + public void LoadGrayscaleImageForAdvertisingWithoutCachedImagesReturnsNull() + { + // Arrange + IModificationImageFileService imageFileService = Substitute.For(); + imageFileService.CountImageFiles("Sponsor").Returns(0); + ModificationTileImageProvider provider = CreateProvider(imageFileService); + ModificationVersion version = CreateVersion(ModificationType.Advertising); + GameModification modification = new(version) + { + Name = ":Sponsor:", + ModificationType = ModificationType.Advertising + }; + + // Act + ImageSource? result = provider.LoadGrayscaleImage(modification, version, localMod: false); + + // Assert + result.Should().BeNull(); + imageFileService.Received(1).CountImageFiles("Sponsor"); + imageFileService.DidNotReceive().FindExistingImageFilePath(Arg.Any(), Arg.Any()); + } + + [Fact] + public void LoadGrayscaleImageForAdvertisingLoadsExistingIndexedImage() + { + // Arrange + const string ImagePath = @"C:\Cache\Sponsor\0.png"; + IModificationImageFileService imageFileService = Substitute.For(); + imageFileService.CountImageFiles("Sponsor").Returns(2); + imageFileService + .FindExistingImageFilePath(Arg.Is("Sponsor"), Arg.Any()) + .Returns(ImagePath); + imageFileService.ImageExists(ImagePath).Returns(true); + BitmapSource advertisingImage = CreateImage(); + ModificationTileImageProvider provider = CreateProvider( + imageFileService, + loadFileImage: (_, _) => advertisingImage); + ModificationVersion version = CreateVersion(ModificationType.Advertising); + GameModification modification = new(version) + { + Name = "Sponsor", + ModificationType = ModificationType.Advertising + }; + + // Act + ImageSource? result = provider.LoadGrayscaleImage(modification, version, localMod: false); + + // Assert + result.Should().BeSameAs(advertisingImage); + imageFileService.Received(1).FindExistingImageFilePath( + "Sponsor", + Arg.Any()); + } + + [Fact] + public void LoadColorImageDeletesInvalidCachedModImageAndUsesDefaultImage() + { + // Arrange + const string ImagePath = @"C:\Cache\ShockWave\1.0.png"; + IModificationImageFileService imageFileService = Substitute.For(); + imageFileService.FindExistingImageFilePath("ShockWave", "1.0").Returns(ImagePath); + imageFileService.ImageExists(ImagePath).Returns(true); + BitmapSource defaultImage = CreateImage(); + ModificationTileImageProvider provider = CreateProvider( + imageFileService, + loadFileImage: (_, _) => throw new IOException("Invalid image."), + loadDefaultImage: (_, _) => defaultImage); + + // Act + ImageSource? result = provider.LoadColorImage( + new GameModification(CreateVersion()), + CreateVersion(), + localMod: false); + + // Assert + result.Should().BeSameAs(defaultImage); + imageFileService.Received(1).TryDeleteImage(ImagePath); + } + + [Fact] + public void LoadGrayscaleImageReturnsNullWhenInvalidAdvertisingImageCannotBeDeleted() + { + // Arrange + const string ImagePath = @"C:\Cache\Sponsor\0.png"; + IModificationImageFileService imageFileService = Substitute.For(); + imageFileService.CountImageFiles("Sponsor").Returns(2); + imageFileService.FindExistingImageFilePath("Sponsor", Arg.Any()).Returns(ImagePath); + imageFileService.ImageExists(ImagePath).Returns(true); + imageFileService + .When(service => service.TryDeleteImage(ImagePath)) + .Do(_ => throw new UnauthorizedAccessException("Denied.")); + ModificationTileImageProvider provider = CreateProvider( + imageFileService, + loadFileImage: (_, _) => throw new IOException("Invalid image.")); + ModificationVersion version = CreateVersion(ModificationType.Advertising); + GameModification modification = new(version) + { + Name = "Sponsor", + ModificationType = ModificationType.Advertising + }; + + // Act + ImageSource? result = provider.LoadGrayscaleImage(modification, version, localMod: false); + + // Assert + result.Should().BeNull(); + imageFileService.Received(1).TryDeleteImage(ImagePath); + } + + private static ModificationTileImageProvider CreateProvider( + IModificationImageFileService imageFileService, + Func? canLoadImages = null, + Func? loadFileImage = null, + Func? loadDefaultImage = null) + { + return new ModificationTileImageProvider( + CreateImageSourceFactory(), + new TestLauncherModsContext(), + imageFileService, + NullLogger.Instance, + canLoadImages ?? (() => true), + loadFileImage ?? ((_, _) => CreateImage()), + loadDefaultImage ?? ((_, _) => CreateImage())); + } + + private static ModificationImageSourceFactory CreateImageSourceFactory() + { + return new ModificationImageSourceFactory(NullLogger.Instance); + } + + private static BitmapSource CreateImage() + { + var image = BitmapSource.Create( + 1, + 1, + 96, + 96, + PixelFormats.Bgra32, + null, + new byte[] { 255, 255, 255, 255 }, + 4); + image.Freeze(); + return image; + } + + private static ModificationVersion CreateVersion(ModificationType modificationType = ModificationType.Mod) + { + return new ModificationVersion + { + Name = "ShockWave", + Version = "1.0", + ModificationType = modificationType + }; + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Mods/ViewModels/ModificationTileStateTests.cs b/GenLauncherGO.Tests/UI/Features/Mods/ViewModels/ModificationTileStateTests.cs new file mode 100644 index 00000000..9b7bae6c --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Mods/ViewModels/ModificationTileStateTests.cs @@ -0,0 +1,165 @@ +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Mods.ViewModels; + +namespace GenLauncherGO.Tests.UI.Features.Mods.ViewModels; + +public sealed class ModificationTileStateTests +{ + [Fact] + public void ConstructorSelectsPersistedInstalledVersion() + { + // Arrange + ModificationVersion firstVersion = new() + { + Name = "Test Mod", + Version = "1.0", + Installed = true + }; + ModificationVersion selectedVersion = new() + { + Name = "Test Mod", + Version = "2.0", + Installed = true, + IsSelected = true + }; + GameModification modification = CreateModification(firstVersion, selectedVersion); + + // Act + ModificationTileState state = new(modification, new TestStringLocalizer()); + + // Assert + state.SelectedVersion.Should().BeSameAs(selectedVersion); + state.LatestVersion.Should().BeSameAs(selectedVersion); + state.LatestVersionInfo.Should().Be("Latest version: 2.0"); + } + + [Fact] + public void ConstructorFallsBackToFirstInstalledVersion() + { + // Arrange + ModificationVersion installedVersion = new() + { + Name = "Test Mod", + Version = "1.0", + Installed = true + }; + ModificationVersion remoteVersion = new() + { + Name = "Test Mod", + Version = "2.0" + }; + GameModification modification = CreateModification(installedVersion, remoteVersion); + + // Act + ModificationTileState state = new(modification, new TestStringLocalizer()); + + // Assert + state.SelectedVersion.Should().BeSameAs(installedVersion); + installedVersion.IsSelected.Should().BeTrue(); + state.LatestVersion.Should().BeSameAs(remoteVersion); + } + + [Fact] + public void ConstructorDetectsLocalOnlyModification() + { + // Arrange + ModificationVersion localVersion = new() + { + Name = "Test Mod", + Version = "1.0", + Installed = true + }; + GameModification modification = CreateModification(localVersion); + + // Act + ModificationTileState state = new(modification, new TestStringLocalizer()); + + // Assert + state.LocalMod.Should().BeTrue(); + } + + [Fact] + public void ConstructorDetectsManagedModification() + { + // Arrange + ModificationVersion managedVersion = new() + { + Name = "Test Mod", + Version = "1.0", + Installed = true, + S3BucketName = "mods", + S3FolderName = "test-mod" + }; + GameModification modification = CreateModification(managedVersion); + + // Act + ModificationTileState state = new(modification, new TestStringLocalizer()); + + // Assert + state.LocalMod.Should().BeFalse(); + } + + [Fact] + public void ConstructorLeavesAdvertisingVersionTextUnprefixed() + { + // Arrange + ModificationVersion advertisingVersion = new() + { + Name = "Sponsor", + Version = "Summer", + ModificationType = ModificationType.Advertising + }; + GameModification modification = CreateModification(advertisingVersion); + modification.ModificationType = ModificationType.Advertising; + + // Act + ModificationTileState state = new(modification, new TestStringLocalizer()); + + // Assert + state.LatestVersionInfo.Should().Be("Summer"); + } + + [Fact] + public void SelectLatestInstalledVersionClearsPreviousSelectionAndMarksReady() + { + // Arrange + ModificationVersion oldVersion = new() + { + Name = "Test Mod", + Version = "1.0", + Installed = true, + IsSelected = true + }; + ModificationVersion newVersion = new() + { + Name = "Test Mod", + Version = "2.0", + Installed = true + }; + GameModification modification = CreateModification(oldVersion, newVersion); + ModificationTileState state = new(modification, new TestStringLocalizer()); + state.MarkNotReadyToRun(); + + // Act + ModificationVersion selectedVersion = state.SelectLatestInstalledVersion(); + + // Assert + selectedVersion.Should().BeSameAs(newVersion); + oldVersion.IsSelected.Should().BeFalse(); + newVersion.IsSelected.Should().BeTrue(); + state.ReadyToRun.Should().BeTrue(); + } + + private static GameModification CreateModification(params ModificationVersion[] versions) + { + return new GameModification + { + Name = "Test Mod", + ModificationType = ModificationType.Mod, + ModificationVersions = versions.ToList() + }; + } + +} diff --git a/GenLauncherGO.Tests/UI/Features/Mods/ViewModels/ModsDialogViewModelTests.cs b/GenLauncherGO.Tests/UI/Features/Mods/ViewModels/ModsDialogViewModelTests.cs new file mode 100644 index 00000000..aa958293 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Mods/ViewModels/ModsDialogViewModelTests.cs @@ -0,0 +1,479 @@ +using System; +using System.Collections.Generic; +using System.Windows; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Models; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Mods.ViewModels; +using GenLauncherGO.Tests.Testing; + +namespace GenLauncherGO.Tests.UI.Features.Mods.ViewModels; + +public sealed class ModsDialogViewModelTests +{ + [Fact] + public void AddModificationAcceptCommand_WithSelection_RequestsAcceptedClose() + { + // Arrange + AddModificationViewModel viewModel = new(new[] { "Contra", "ShockWave" }); + viewModel.SelectedModificationName = "Contra"; + bool closeRequested = false; + viewModel.CloseRequested += (_, _) => closeRequested = true; + + // Act + viewModel.AcceptCommand.Execute(null); + + // Assert + closeRequested.Should().BeTrue(); + viewModel.DialogResult.Should().BeTrue(); + viewModel.SelectedModificationName.Should().Be("Contra"); + } + + [Fact] + public void AddModificationCancelCommand_RequestsCanceledClose() + { + // Arrange + AddModificationViewModel viewModel = new(new[] { "Contra" }); + bool closeRequested = false; + viewModel.CloseRequested += (_, _) => closeRequested = true; + + // Act + viewModel.CancelCommand.Execute(null); + + // Assert + closeRequested.Should().BeTrue(); + viewModel.DialogResult.Should().BeFalse(); + } + + [Fact] + public void ManualAddAcceptCommand_WithMissingName_ShowsLocalizedErrorWithoutClosing() + { + // Arrange + FakeDialogService dialogService = new(); + ManualAddModificationViewModel viewModel = new( + new[] { @"C:\Temp\mod.zip" }, + null, + CreateStringLocalizer(), + dialogService) + { + ModificationName = string.Empty, + Version = "1.0" + }; + bool closeRequested = false; + viewModel.CloseRequested += (_, _) => closeRequested = true; + + // Act + viewModel.AcceptCommand.Execute(null); + + // Assert + viewModel.AcceptCommand.CanExecute(null).Should().BeFalse(); + viewModel.ModificationNameValidationMessage.Should().Be("Enter a modification name"); + closeRequested.Should().BeFalse(); + dialogService.LastErrorRequest.Should().NotBeNull(); + dialogService.LastErrorRequest!.MainMessage.Should().Be("Operation aborted"); + dialogService.LastErrorRequest.DetailMessage.Should().Be("Enter a modification name"); + viewModel.DialogResult.Should().BeNull(); + } + + [Fact] + public void ManualAddAcceptCommand_WithMissingVersion_ShowsLocalizedErrorWithoutClosing() + { + // Arrange + FakeDialogService dialogService = new(); + ManualAddModificationViewModel viewModel = new( + new[] { @"C:\Temp\mod.zip" }, + null, + CreateStringLocalizer(), + dialogService) + { + ModificationName = "ShockWave" + }; + + // Act + viewModel.AcceptCommand.Execute(null); + + // Assert + viewModel.AcceptCommand.CanExecute(null).Should().BeFalse(); + viewModel.VersionValidationMessage.Should().Be("Enter a version"); + dialogService.LastErrorRequest.Should().NotBeNull(); + dialogService.LastErrorRequest!.DetailMessage.Should().Be("Enter a version"); + viewModel.DialogResult.Should().BeNull(); + } + + [Fact] + public void ManualAddAcceptCommand_WithVersionWithoutDigits_ShowsLocalizedErrorWithoutClosing() + { + // Arrange + FakeDialogService dialogService = new(); + ManualAddModificationViewModel viewModel = new( + new[] { @"C:\Temp\mod.zip" }, + null, + CreateStringLocalizer(), + dialogService) + { + ModificationName = "ShockWave", + Version = "release" + }; + + // Act + viewModel.AcceptCommand.Execute(null); + + // Assert + viewModel.AcceptCommand.CanExecute(null).Should().BeFalse(); + viewModel.VersionValidationMessage.Should().Be("Version must contain numbers"); + dialogService.LastErrorRequest.Should().NotBeNull(); + dialogService.LastErrorRequest!.DetailMessage.Should().Be("Version must contain numbers"); + viewModel.DialogResult.Should().BeNull(); + } + + [Fact] + public void ManualAddAcceptCommand_WithUnsupportedNameCharacters_ShowsLocalizedErrorWithoutClosing() + { + // Arrange + FakeDialogService dialogService = new(); + ManualAddModificationViewModel viewModel = new( + new[] { @"C:\Temp\mod.zip" }, + null, + CreateStringLocalizer(), + dialogService) + { + ModificationName = "!!!", + Version = "1.0" + }; + + // Act + viewModel.AcceptCommand.Execute(null); + + // Assert + viewModel.AcceptCommand.CanExecute(null).Should().BeFalse(); + viewModel.ModificationNameValidationMessage.Should().Be("Name and version must contain supported symbols"); + dialogService.LastErrorRequest.Should().NotBeNull(); + dialogService.LastErrorRequest!.DetailMessage.Should().Be("Name and version must contain supported symbols"); + viewModel.DialogResult.Should().BeNull(); + } + + [Fact] + public void ManualAddAcceptCommand_WithValidInput_CreatesImportResultAndCloses() + { + // Arrange + ManualAddModificationViewModel viewModel = new( + new[] { @"C:\Temp\mod.zip" }, + "Parent Mod", + CreateStringLocalizer(), + new FakeDialogService()) + { + ModificationName = "Patch Pack", + Version = "1.2" + }; + bool closeRequested = false; + viewModel.CloseRequested += (_, _) => closeRequested = true; + + // Act + viewModel.AcceptCommand.Execute(null); + + // Assert + viewModel.AcceptCommand.CanExecute(null).Should().BeTrue(); + viewModel.ModificationNameValidationMessage.Should().BeEmpty(); + viewModel.VersionValidationMessage.Should().BeEmpty(); + closeRequested.Should().BeTrue(); + viewModel.DialogResult.Should().BeTrue(); + viewModel.ImportResult.Should().NotBeNull(); + viewModel.ImportResult!.ParentContentName.Should().Be("Parent Mod"); + viewModel.ImportResult.ModificationName.Should().Be("Patch Pack"); + viewModel.ImportResult.Version.Should().Be("1.2"); + viewModel.ImportResult.Files.Should().ContainSingle().Which.Should().Be(@"C:\Temp\mod.zip"); + } + + [Fact] + public void ManualAddCancelCommand_RequestsCanceledClose() + { + // Arrange + ManualAddModificationViewModel viewModel = new( + new[] { @"C:\Temp\mod.zip" }, + null, + CreateStringLocalizer(), + new FakeDialogService()); + bool closeRequested = false; + viewModel.CloseRequested += (_, _) => closeRequested = true; + + // Act + viewModel.CancelCommand.Execute(null); + + // Assert + closeRequested.Should().BeTrue(); + viewModel.DialogResult.Should().BeFalse(); + viewModel.ImportResult.Should().BeNull(); + } + + [Fact] + public void ManualAddConstructor_InfersModificationNameFromFirstSelectedFile() + { + // Act + ManualAddModificationViewModel viewModel = new( + new[] { @"C:\Temp\ShockWave-1.2.zip", @"C:\Temp\ignored.big" }, + null, + CreateStringLocalizer(), + new FakeDialogService()); + + // Assert + viewModel.ModificationName.Should().Be("ShockWave-1.2"); + viewModel.ModificationNameValidationMessage.Should().BeEmpty(); + viewModel.AcceptCommand.CanExecute(null).Should().BeFalse(); + } + + [Fact] + public void ManualAddFields_UpdateValidationStateImmediately() + { + // Arrange + ManualAddModificationViewModel viewModel = new( + new[] { @"C:\Temp\mod.zip" }, + null, + CreateStringLocalizer(), + new FakeDialogService()); + + // Act and Assert + viewModel.AcceptCommand.CanExecute(null).Should().BeFalse(); + viewModel.VersionValidationMessage.Should().Be("Enter a version"); + + viewModel.ModificationName = "ShockWave"; + viewModel.Version = "release"; + + viewModel.AcceptCommand.CanExecute(null).Should().BeFalse(); + viewModel.VersionValidationMessage.Should().Be("Version must contain numbers"); + + viewModel.Version = "1.2"; + + viewModel.AcceptCommand.CanExecute(null).Should().BeTrue(); + viewModel.ModificationNameValidationMessage.Should().BeEmpty(); + viewModel.VersionValidationMessage.Should().BeEmpty(); + } + + [Fact] + public void ManualAddConstructorThrowsForMissingDependencies() + { + // Act + Action missingFiles = () => new ManualAddModificationViewModel( + null!, + null, + CreateStringLocalizer(), + new FakeDialogService()); + Action missingLocalizer = () => new ManualAddModificationViewModel( + Array.Empty(), + null, + null!, + new FakeDialogService()); + Action missingDialogService = () => new ManualAddModificationViewModel( + Array.Empty(), + null, + CreateStringLocalizer(), + null!); + + // Assert + missingFiles.Should().Throw(); + missingLocalizer.Should().Throw(); + missingDialogService.Should().Throw(); + } + + [Fact] + public void InfoDialogConstructor_ForInfo_ConfiguresOkOnlyNeutralState() + { + // Act + InfoDialogViewModel viewModel = new( + new LauncherInfoDialogRequest("Information", "Details"), + InfoDialogKind.Info); + + // Assert + viewModel.OkVisibility.Should().Be(Visibility.Visible); + viewModel.ContinueVisibility.Should().Be(Visibility.Hidden); + viewModel.CancelVisibility.Should().Be(Visibility.Hidden); + viewModel.InfoIconVisibility.Should().Be(Visibility.Visible); + viewModel.ErrorIconVisibility.Should().Be(Visibility.Hidden); + viewModel.WarningIconVisibility.Should().Be(Visibility.Hidden); + } + + [Fact] + public void InfoDialogConstructorThrowsForMissingRequest() + { + // Act + Action act = () => new InfoDialogViewModel(null!, InfoDialogKind.Info); + + // Assert + act.Should().Throw(); + } + + [Fact] + public void InfoDialogConstructor_ForError_ConfiguresOkOnlyErrorState() + { + // Act + InfoDialogViewModel viewModel = new( + new LauncherInfoDialogRequest("Error", "Details", 12D), + InfoDialogKind.Error); + + // Assert + viewModel.MainMessage.Should().Be("Error"); + viewModel.DetailMessage.Should().Be("Details"); + viewModel.DetailFontSize.Should().Be(12D); + viewModel.OkVisibility.Should().Be(Visibility.Visible); + viewModel.ContinueVisibility.Should().Be(Visibility.Hidden); + viewModel.CancelVisibility.Should().Be(Visibility.Hidden); + viewModel.InfoIconVisibility.Should().Be(Visibility.Hidden); + viewModel.ErrorIconVisibility.Should().Be(Visibility.Visible); + viewModel.WarningIconVisibility.Should().Be(Visibility.Hidden); + } + + [Fact] + public void InfoDialogOkCommand_ForInfo_AcceptsAndRequestsClose() + { + // Arrange + InfoDialogViewModel viewModel = new( + new LauncherInfoDialogRequest("Information", "Details"), + InfoDialogKind.Info); + bool closeRequested = false; + viewModel.CloseRequested += (_, _) => closeRequested = true; + + // Act + viewModel.OkCommand.Execute(null); + + // Assert + closeRequested.Should().BeTrue(); + viewModel.ChoseAnOption.Should().BeTrue(); + viewModel.ContinueLaunch.Should().BeTrue(); + viewModel.ShouldHideOnCloseRequest.Should().BeFalse(); + } + + [Fact] + public void InfoDialogCloseCommand_ForInfo_AcceptsAndRequestsClose() + { + // Arrange + InfoDialogViewModel viewModel = new( + new LauncherInfoDialogRequest("Information", "Details"), + InfoDialogKind.Info); + + // Act + viewModel.CloseCommand.Execute(null); + + // Assert + viewModel.ChoseAnOption.Should().BeTrue(); + viewModel.ContinueLaunch.Should().BeTrue(); + viewModel.ShouldHideOnCloseRequest.Should().BeFalse(); + } + + [Fact] + public void InfoDialogCancelCommand_ForWarning_SetsNegativeResultAndRequestsHide() + { + // Arrange + InfoDialogViewModel viewModel = new( + new LauncherInfoDialogRequest("Warning", "Details"), + InfoDialogKind.WarningConfirmation, + "Continue anyway"); + bool closeRequested = false; + viewModel.CloseRequested += (_, _) => closeRequested = true; + + // Act + viewModel.CancelCommand.Execute(null); + + // Assert + closeRequested.Should().BeTrue(); + viewModel.ChoseAnOption.Should().BeTrue(); + viewModel.ContinueLaunch.Should().BeFalse(); + viewModel.ShouldHideOnCloseRequest.Should().BeTrue(); + viewModel.ContinueText.Should().Be("Continue anyway"); + } + + [Fact] + public void InfoDialogContinueCommand_ForWarning_SetsPositiveResultAndRequestsHide() + { + // Arrange + InfoDialogViewModel viewModel = new( + new LauncherInfoDialogRequest("Warning", "Details"), + InfoDialogKind.WarningConfirmation, + " "); + bool closeRequested = false; + viewModel.CloseRequested += (_, _) => closeRequested = true; + + // Act + viewModel.ContinueCommand.Execute(null); + + // Assert + closeRequested.Should().BeTrue(); + viewModel.ChoseAnOption.Should().BeTrue(); + viewModel.ContinueLaunch.Should().BeTrue(); + viewModel.ShouldHideOnCloseRequest.Should().BeTrue(); + viewModel.ContinueText.Should().BeNull(); + } + + [Fact] + public void InfoDialogCloseCommand_ForWarning_CancelsAndRequestsHide() + { + // Arrange + InfoDialogViewModel viewModel = new( + new LauncherInfoDialogRequest("Warning", "Details"), + InfoDialogKind.WarningConfirmation); + + // Act + viewModel.CloseCommand.Execute(null); + + // Assert + viewModel.ChoseAnOption.Should().BeTrue(); + viewModel.ContinueLaunch.Should().BeFalse(); + viewModel.ShouldHideOnCloseRequest.Should().BeTrue(); + } + + private static TestStringLocalizer CreateStringLocalizer() + { + return new TestStringLocalizer(new Dictionary + { + ["EnterModName"] = "Enter a modification name", + ["EnterModVersion"] = "Enter a version", + ["NameAndVersionValidSymbols"] = "Name and version must contain supported symbols", + ["OperationAborted"] = "Operation aborted", + ["VersionMustContainNumbers"] = "Version must contain numbers", + }); + } + + private sealed class FakeDialogService : ILauncherDialogService + { + public LauncherInfoDialogRequest? LastErrorRequest { get; private set; } + + public LauncherInfoDialogRequest? LastInfoRequest { get; private set; } + + public void ShowInfo(LauncherInfoDialogRequest request, Window? owner = null) + { + LastInfoRequest = request; + } + + public void ShowError(LauncherInfoDialogRequest request, Window? owner = null) + { + LastErrorRequest = request; + } + + public bool ShowWarningConfirmation( + LauncherInfoDialogRequest request, + string? continueText = null, + Window? owner = null) + { + return true; + } + + public string? ShowModificationSelection(IReadOnlyList modificationNames, Window? owner = null) + { + return null; + } + + public ManualModificationDialogResult? ShowManualModificationImport( + ManualModificationDialogRequest request, + Window? owner = null) + { + return null; + } + + public bool ShowIntegrityReview( + ContentIntegrityReport report, + IntegrityReviewDialogOptions options, + Window? owner = null) + { + return false; + } + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Settings/LauncherSettingsUiServiceCollectionExtensionsTests.cs b/GenLauncherGO.Tests/UI/Features/Settings/LauncherSettingsUiServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..f644d55b --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Settings/LauncherSettingsUiServiceCollectionExtensionsTests.cs @@ -0,0 +1,22 @@ +using GenLauncherGO.UI.Features.Settings.Composition; +using GenLauncherGO.UI.Features.Settings.Contracts; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Tests.UI.Features.Settings; + +public sealed class LauncherSettingsUiServiceCollectionExtensionsTests +{ + [Fact] + public void AddGenLauncherGoLauncherSettingsUi_RegistersLauncherSettingsWindowFactory() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoLauncherSettingsUi(); + using ServiceProvider provider = services.BuildServiceProvider(); + + // Assert + provider.GetRequiredService().Should().NotBeNull(); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Settings/LauncherSettingsViewModelTests.cs b/GenLauncherGO.Tests/UI/Features/Settings/LauncherSettingsViewModelTests.cs new file mode 100644 index 00000000..251a4771 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Settings/LauncherSettingsViewModelTests.cs @@ -0,0 +1,184 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Settings.Contracts; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.UI.Features.Settings.Contracts; +using GenLauncherGO.UI.Features.Settings.Models; +using GenLauncherGO.UI.Features.Settings.ViewModels; + +namespace GenLauncherGO.Tests.UI.Features.Settings; + +public sealed class LauncherSettingsViewModelTests +{ + [Fact] + public void GameArguments_WhenChanged_PersistsImmediately() + { + // Arrange + var preferencesService = new RecordingLauncherPreferencesService(new LauncherPreferences()); + LauncherSettingsViewModel viewModel = CreateViewModel(preferencesService); + + // Act + viewModel.GameArguments = "-quickstart"; + + // Assert + preferencesService.Updates.Should().ContainSingle(); + preferencesService.Current.GameArguments.Should().Be("-quickstart"); + viewModel.GameArguments.Should().Be("-quickstart"); + } + + [Fact] + public void UseSystemLanguage_WhenSelected_PersistsAppliesCultureAndRefreshesText() + { + // Arrange + var preferences = new LauncherPreferences { UseEnglishLanguage = true }; + var preferencesService = new RecordingLauncherPreferencesService(preferences); + var cultureService = new RecordingLauncherCultureService(); + var textProvider = new CountingLauncherSettingsTextProvider(); + LauncherSettingsViewModel viewModel = CreateViewModel(preferencesService, cultureService, textProvider); + + // Act + viewModel.UseSystemLanguage = true; + + // Assert + preferencesService.Updates.Should().ContainSingle(); + preferencesService.Current.UseEnglishLanguage.Should().BeFalse(); + cultureService.AppliedPreferences.Should().ContainSingle().Which.Should().BeFalse(); + textProvider.CallCount.Should().Be(2); + viewModel.UseEnglishLanguage.Should().BeFalse(); + viewModel.UseSystemLanguage.Should().BeTrue(); + } + + [Fact] + public void CloseCommand_WhenExecuted_RaisesCloseRequest() + { + // Arrange + var preferencesService = new RecordingLauncherPreferencesService(new LauncherPreferences()); + LauncherSettingsViewModel viewModel = CreateViewModel(preferencesService); + bool closeRequested = false; + viewModel.CloseRequested += (_, _) => closeRequested = true; + + // Act + viewModel.CloseCommand.Execute(null); + + // Assert + closeRequested.Should().BeTrue(); + } + + [Fact] + public void LinkCommands_WhenExecuted_InvokeLinkService() + { + // Arrange + var preferencesService = new RecordingLauncherPreferencesService(new LauncherPreferences()); + var linkService = new RecordingLauncherSettingsLinkService(); + var viewModel = new LauncherSettingsViewModel( + preferencesService, + linkService, + new CountingLauncherSettingsTextProvider(), + new RecordingLauncherCultureService()); + + // Act + viewModel.OpenGeneralsOnlineDiscordCommand.Execute(null); + viewModel.OpenLogsDirectoryCommand.Execute(null); + viewModel.OpenGitHubRepositoryCommand.Execute(null); + viewModel.OpenDonationCommand.Execute(null); + + // Assert + linkService.OpenedGeneralsOnlineDiscord.Should().BeTrue(); + linkService.OpenedLogsDirectory.Should().BeTrue(); + linkService.OpenedGitHubRepository.Should().BeTrue(); + linkService.OpenedOriginalAuthorDonation.Should().BeTrue(); + } + + private static LauncherSettingsViewModel CreateViewModel( + RecordingLauncherPreferencesService preferencesService, + RecordingLauncherCultureService? cultureService = null, + CountingLauncherSettingsTextProvider? textProvider = null) + { + return new LauncherSettingsViewModel( + preferencesService, + new RecordingLauncherSettingsLinkService(), + textProvider ?? new CountingLauncherSettingsTextProvider(), + cultureService ?? new RecordingLauncherCultureService()); + } + + private sealed class RecordingLauncherPreferencesService : ILauncherPreferencesService + { + public RecordingLauncherPreferencesService(LauncherPreferences preferences) + { + Current = preferences; + } + + public event EventHandler? PreferencesChanged; + + public LauncherPreferences Current { get; private set; } + + public List Updates { get; } = new List(); + + public void Update(LauncherPreferences preferences) + { + Current = preferences; + Updates.Add(preferences); + PreferencesChanged?.Invoke(this, new LauncherPreferencesChangedEventArgs(preferences)); + } + } + + private sealed class RecordingLauncherCultureService : ILauncherCultureService + { + public List AppliedPreferences { get; } = new List(); + + public void ApplyLanguagePreference(bool useEnglishLanguage) + { + AppliedPreferences.Add(useEnglishLanguage); + } + } + + private sealed class CountingLauncherSettingsTextProvider : ILauncherSettingsTextProvider + { + public int CallCount { get; private set; } + + public LauncherSettingsText GetText() + { + CallCount++; + return new LauncherSettingsText + { + Title = "Launcher Settings", + Ok = "OK" + }; + } + } + + private sealed class RecordingLauncherSettingsLinkService : ILauncherSettingsLinkService + { + public bool OpenedGeneralsOnlineDiscord { get; private set; } + + public bool OpenedLogsDirectory { get; private set; } + + public bool OpenedGitHubRepository { get; private set; } + + public bool OpenedOriginalAuthorDonation { get; private set; } + + public bool TryOpenGeneralsOnlineDiscordLink() + { + OpenedGeneralsOnlineDiscord = true; + return true; + } + + public bool TryOpenLogsDirectory() + { + OpenedLogsDirectory = true; + return true; + } + + public bool TryOpenGitHubRepository() + { + OpenedGitHubRepository = true; + return true; + } + + public bool TryOpenOriginalAuthorDonationLink() + { + OpenedOriginalAuthorDonation = true; + return true; + } + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Settings/Localization/LocalizedLauncherSettingsTextProviderTests.cs b/GenLauncherGO.Tests/UI/Features/Settings/Localization/LocalizedLauncherSettingsTextProviderTests.cs new file mode 100644 index 00000000..a79de7b4 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Settings/Localization/LocalizedLauncherSettingsTextProviderTests.cs @@ -0,0 +1,66 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Settings.Localization; +using GenLauncherGO.UI.Features.Settings.Models; + +namespace GenLauncherGO.Tests.UI.Features.Settings.Localization; + +public sealed class LocalizedLauncherSettingsTextProviderTests +{ + [Fact] + public void ConstructorThrowsForMissingLocalizer() + { + // Arrange + Action act = () => new LocalizedLauncherSettingsTextProvider(null!); + + // Act and Assert + act.Should().Throw(); + } + + [Fact] + public void GetText_MapsAllSettingsLabelsFromResourceKeys() + { + // Arrange + LocalizedLauncherSettingsTextProvider provider = new(new TestStringLocalizer( + new Dictionary(), + key => $"localized:{key}")); + + // Act + LauncherSettingsText text = provider.GetText(); + + // Assert + text.Should().BeEquivalentTo(new LauncherSettingsText + { + Title = "localized:Options", + LaunchArguments = "localized:LaunchArguments", + GameExecutableArguments = "localized:GameExecutableArguments", + GameExecutableArgumentsToolTip = "localized:GameExecutableArgumentsToolTip", + GameExecutableArgumentsInputToolTip = "localized:GameExecutableArgumentsInputToolTip", + WorldBuilderArguments = "localized:WorldBuilderArguments", + WorldBuilderArgumentsToolTip = "localized:WorldBuilderArgumentsToolTip", + WorldBuilderArgumentsInputToolTip = "localized:WorldBuilderArgumentsInputToolTip", + Maintenance = "localized:Maintenance", + AutoDelete = "localized:AutoDelete", + AutoDeleteToolTip = "localized:AutoDeleteToolTip", + Language = "localized:Language", + UseEnglishLanguage = "localized:UseEnglishLanguage", + UseEnglishLanguageToolTip = "localized:UseEnglishLanguageToolTip", + UseSystemLanguage = "localized:UseSystemLanguage", + UseSystemLanguageToolTip = "localized:UseSystemLanguageToolTip", + WindowBehavior = "localized:WindowBehavior", + HideLauncher = "localized:HideLauncher", + HideLauncherToolTip = "localized:HideLauncherToolTip", + Links = "localized:Links", + GeneralsOnlineDiscord = "localized:GeneralsOnlineDiscord", + GeneralsOnlineDiscordToolTip = "localized:GeneralsOnlineDiscordToolTip", + OpenLogsFolder = "localized:OpenLogsFolder", + OpenLogsFolderToolTip = "localized:OpenLogsFolderToolTip", + GitHubRepository = "localized:GitHubRepository", + GitHubRepositoryToolTip = "localized:GitHubRepositoryToolTip", + DonateOriginalAuthor = "localized:DonateOriginalAuthor", + DonateOriginalAuthorToolTip = "localized:DonateOriginalAuthorToolTip", + Ok = "localized:Ok", + }); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Startup/LauncherApplicationHostTests.cs b/GenLauncherGO.Tests/UI/Features/Startup/LauncherApplicationHostTests.cs new file mode 100644 index 00000000..7fec9792 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Startup/LauncherApplicationHostTests.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Startup.Contracts; +using GenLauncherGO.UI.Features.Startup; +using GenLauncherGO.UI.Features.Startup.Contracts; +using GenLauncherGO.UI.Shared.Localization; +using GenLauncherGO.UI.Shared.Themes; + +namespace GenLauncherGO.Tests.UI.Features.Startup; + +[Collection("WpfLocalization")] +public sealed class LauncherApplicationHostTests +{ + [Fact] + public void RunWhenLauncherPathsCannotBeResolvedShowsMoveLauncherMessage() + { + // Arrange + WpfLauncherStringLocalizer localizer = new(); + var pathResolver = new StubLauncherPathResolver(); + var hostEnvironment = new StubLauncherHostEnvironmentService(); + var startupDialogService = new RecordingStartupDialogService(); + using LauncherApplicationHost host = new( + pathResolver, + hostEnvironment, + localizer, + startupDialogService); + + // Act + host.Run(); + + // Assert + startupDialogService.Messages.Should().ContainSingle().Which.Should().NotBeNullOrWhiteSpace(); + pathResolver.ResolvedExecutableDirectory.Should().Be(hostEnvironment.ExecutableDirectory); + pathResolver.PrepareLauncherDirectoriesCount.Should().Be(0); + hostEnvironment.SetCurrentDirectories.Should().BeEmpty(); + } + + [Fact] + public void RunWhenGameDirectoryIsProtectedAndProcessIsNotElevatedShowsProtectedDirectoryMessage() + { + // Arrange + LauncherPaths paths = CreatePaths(); + WpfLauncherStringLocalizer localizer = new(); + var pathResolver = new StubLauncherPathResolver + { + ResolvedPaths = paths, + }; + var hostEnvironment = new StubLauncherHostEnvironmentService + { + ProtectedProgramFilesDirectory = true, + CurrentProcessElevated = false, + }; + var startupDialogService = new RecordingStartupDialogService(); + using LauncherApplicationHost host = new( + pathResolver, + hostEnvironment, + localizer, + startupDialogService); + + // Act + host.Run(); + + // Assert + startupDialogService.Messages.Should() + .ContainSingle() + .Which.Should() + .Contain(paths.GameDirectory); + hostEnvironment.SetCurrentDirectories.Should().Equal(paths.GameDirectory); + pathResolver.PrepareLauncherDirectoriesCount.Should().Be(0); + } + + private static LauncherPaths CreatePaths() + { + return new LauncherPaths( + @"C:\Program Files\ZeroHour", + @"C:\Program Files\ZeroHour\GenLauncherGO", + @"C:\Program Files\ZeroHour\GenLauncherGO\Runtime", + @"C:\Program Files\ZeroHour\GenLauncherGO\Runtime\Cache", + @"C:\Program Files\ZeroHour\GenLauncherGO\Runtime\Cache\Images", + @"C:\Program Files\ZeroHour\GenLauncherGO\Mods", + @"C:\Program Files\ZeroHour\GenLauncherGO\Logs", + @"C:\Program Files\ZeroHour\GenLauncherGO\Runtime\Temp", + @"C:\Program Files\ZeroHour\GenLauncherGO\Runtime\Deployment"); + } + + private sealed class StubLauncherPathResolver : ILauncherPathResolver + { + public LauncherPaths? ResolvedPaths { get; init; } + + public string? ResolvedExecutableDirectory { get; private set; } + + public int PrepareLauncherDirectoriesCount { get; private set; } + + public LauncherPaths? Resolve(string executableDirectory) + { + ResolvedExecutableDirectory = executableDirectory; + return ResolvedPaths; + } + + public void PrepareLauncherDirectories(LauncherPaths paths, bool cleanTemporaryDirectory) + { + PrepareLauncherDirectoriesCount++; + } + } + + private sealed class StubLauncherHostEnvironmentService : ILauncherHostEnvironmentService + { + public string ExecutableDirectory { get; init; } = @"C:\Launcher"; + + public bool CurrentProcessElevated { get; init; } = true; + + public bool ProtectedProgramFilesDirectory { get; init; } + + public List SetCurrentDirectories { get; } = new(); + + public void ActivateCurrentProcessWindow() + { + } + + public string GetExecutableDirectory() + { + return ExecutableDirectory; + } + + public bool IsCurrentProcessElevated() + { + return CurrentProcessElevated; + } + + public bool IsProtectedProgramFilesDirectory(string directory) + { + return ProtectedProgramFilesDirectory; + } + + public void SetCurrentDirectory(string directory) + { + SetCurrentDirectories.Add(directory); + } + + public ILauncherSingleInstanceGuard TryAcquireSingleInstance(string instanceName, TimeSpan retryDelay) + { + return new StubLauncherSingleInstanceGuard(); + } + } + + private sealed class StubLauncherSingleInstanceGuard : ILauncherSingleInstanceGuard + { + public bool IsAcquired => true; + + public void Dispose() + { + } + } + + private sealed class RecordingStartupDialogService : IStartupDialogService + { + public List Messages { get; } = new(); + + public void ShowMessage(string message) + { + Messages.Add(message); + } + + public void ShowThemedMessage(string title, string message, ColorsInfo colors) + { + Messages.Add(message); + } + + public bool ShowRetryCancelWarning(string title, string message) + { + Messages.Add(message); + return false; + } + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Startup/StartupUiServiceCollectionExtensionsTests.cs b/GenLauncherGO.Tests/UI/Features/Startup/StartupUiServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..8da41492 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Startup/StartupUiServiceCollectionExtensionsTests.cs @@ -0,0 +1,90 @@ +using System; +using GenLauncherGO.UI.Features.Launcher.Views; +using GenLauncherGO.UI.Features.Startup.Composition; +using GenLauncherGO.UI.Features.Startup.Services; +using GenLauncherGO.UI.Features.Startup.ViewModels; +using GenLauncherGO.UI.Features.Startup.Views; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.Tests.UI.Features.Startup; + +public sealed class StartupUiServiceCollectionExtensionsTests +{ + [Fact] + public void AddGenLauncherGoStartupUiReturnsSameServiceCollection() + { + // Arrange + ServiceCollection services = new(); + + // Act + IServiceCollection returnedServices = services.AddGenLauncherGoStartupUi(); + + // Assert + returnedServices.Should().BeSameAs(services); + } + + [Fact] + public void AddGenLauncherGoStartupUiThrowsForNullServices() + { + // Arrange + ServiceCollection? services = null; + + // Act + Action act = () => services!.AddGenLauncherGoStartupUi(); + + // Assert + act.Should().Throw() + .WithParameterName(nameof(services)); + } + + [Fact] + public void AddGenLauncherGoStartupUiRegistersMainWindowFactory() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoStartupUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(Func) && + descriptor.Lifetime == ServiceLifetime.Transient); + } + + [Fact] + public void AddGenLauncherGoStartupUiRegistersInitWindowWorkflowCoordinator() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoStartupUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(InitWindowWorkflowCoordinator) && + descriptor.Lifetime == ServiceLifetime.Singleton); + } + + [Fact] + public void AddGenLauncherGoStartupUiRegistersStartupWindowsAndViewModels() + { + // Arrange + ServiceCollection services = new(); + + // Act + services.AddGenLauncherGoStartupUi(); + + // Assert + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(InitWindowViewModel) && + descriptor.Lifetime == ServiceLifetime.Transient); + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(InitWindow) && + descriptor.Lifetime == ServiceLifetime.Transient); + services.Should().ContainSingle(descriptor => + descriptor.ServiceType == typeof(MainWindow) && + descriptor.Lifetime == ServiceLifetime.Transient); + } +} diff --git a/GenLauncherGO.Tests/UI/Features/Startup/ViewModels/InitWindowViewModelTests.cs b/GenLauncherGO.Tests/UI/Features/Startup/ViewModels/InitWindowViewModelTests.cs new file mode 100644 index 00000000..43c33d9b --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Startup/ViewModels/InitWindowViewModelTests.cs @@ -0,0 +1,389 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Remote; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Startup.Contracts; +using GenLauncherGO.Core.Startup.Models; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Features.Startup; +using GenLauncherGO.UI.Features.Startup.Contracts; +using GenLauncherGO.UI.Features.Startup.ViewModels; +using GenLauncherGO.UI.Shared.Themes; + +namespace GenLauncherGO.Tests.UI.Features.Startup.ViewModels; + +public sealed class InitWindowViewModelTests +{ + [Fact] + public async Task StartAsync_WhenPreparationSucceeds_RaisesStartupCompletedAndSetsConnectedAsync() + { + // Arrange + LauncherContentCatalogInitializationRequest? initializationRequest = null; + ILauncherPathResolver pathResolver = Substitute.For(); + ILaunchPreparationService launchPreparationService = Substitute.For(); + launchPreparationService.RecoverAsync(Arg.Any(), Arg.Any()) + .Returns(LaunchPreparationResult.Success()); + IRemoteConnectionProbe connectionProbe = Substitute.For(); + connectionProbe.CanConnectAsync(Arg.Any(), Arg.Any()) + .Returns(true); + ILauncherContentCatalogLoader catalogLoader = Substitute.For(); + catalogLoader.ReposModsNames.Returns(new[] { "Contra" }); + catalogLoader.InitDataAsync( + Arg.Do(request => initializationRequest = request), + Arg.Any()) + .Returns(Task.CompletedTask); + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + ILauncherStartupEnvironmentService startupEnvironmentService = + Substitute.For(); + startupEnvironmentService.ReadAsync(Arg.Any(), Arg.Any()) + .Returns(new LauncherStartupEnvironment( + SupportedGame.ZeroHour, + CreateColors(), + null)); + LauncherRuntimeContext runtimeContext = new(CreateLauncherPaths(), "1.2.3"); + InitWindowViewModel viewModel = CreateViewModel( + pathResolver, + launchPreparationService, + connectionProbe, + catalogLoader, + catalogQueries, + startupEnvironmentService, + runtimeContext, + new FakeStartupDialogService()); + InitWindowStartupCompletedEventArgs? completedArgs = null; + viewModel.StartupCompleted += (_, args) => completedArgs = args; + + // Act + await viewModel.StartAsync(); + + // Assert + completedArgs.Should().NotBeNull(); + completedArgs!.Connected.Should().BeTrue(); + runtimeContext.Connected.Should().BeTrue(); + runtimeContext.CurrentlyManagedGame.Should().Be(SupportedGame.ZeroHour); + initializationRequest.Should().NotBeNull(); + initializationRequest!.Connected.Should().BeTrue(); + initializationRequest.RemoteManifestUri.Should().Be(new Uri(LauncherApplicationDefaults.ZeroHourRepositoryUrl)); + pathResolver.Received(1).PrepareLauncherDirectories(runtimeContext.LauncherPaths, true); + } + + [Fact] + public async Task StartAsync_WhenRecoveryCanceled_RequestsShutdownWithoutCompletionAsync() + { + // Arrange + ILaunchPreparationService launchPreparationService = Substitute.For(); + launchPreparationService.RecoverAsync(Arg.Any(), Arg.Any()) + .Returns(LaunchPreparationResult.Failure(Array.Empty())); + ILauncherStartupEnvironmentService startupEnvironmentService = + Substitute.For(); + LauncherRuntimeContext runtimeContext = new(CreateLauncherPaths(), "1.2.3"); + InitWindowViewModel viewModel = CreateViewModel( + Substitute.For(), + launchPreparationService, + Substitute.For(), + Substitute.For(), + Substitute.For(), + startupEnvironmentService, + runtimeContext, + new FakeStartupDialogService(retryRecovery: false)); + bool completed = false; + bool shutdownRequested = false; + viewModel.StartupCompleted += (_, _) => completed = true; + viewModel.ShutdownRequested += (_, _) => shutdownRequested = true; + + // Act + await viewModel.StartAsync(); + + // Assert + completed.Should().BeFalse(); + shutdownRequested.Should().BeTrue(); + runtimeContext.Connected.Should().BeFalse(); + await startupEnvironmentService.DidNotReceive() + .ReadAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task StartAsync_WhenConnectionFails_ShowsThemedStartupMessageAndCompletesOfflineAsync() + { + // Arrange + ILaunchPreparationService launchPreparationService = Substitute.For(); + launchPreparationService.RecoverAsync(Arg.Any(), Arg.Any()) + .Returns(LaunchPreparationResult.Success()); + IRemoteConnectionProbe connectionProbe = Substitute.For(); + connectionProbe.CanConnectAsync(Arg.Any(), Arg.Any()) + .Returns(false); + ILauncherContentCatalogLoader catalogLoader = Substitute.For(); + catalogLoader.InitDataAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.CompletedTask); + ILauncherStartupEnvironmentService startupEnvironmentService = + Substitute.For(); + startupEnvironmentService.ReadAsync(Arg.Any(), Arg.Any()) + .Returns(new LauncherStartupEnvironment( + SupportedGame.ZeroHour, + CreateColors(), + null)); + LauncherRuntimeContext runtimeContext = new(CreateLauncherPaths(), "1.2.3"); + FakeStartupDialogService startupDialogService = new(); + InitWindowViewModel viewModel = CreateViewModel( + Substitute.For(), + launchPreparationService, + connectionProbe, + catalogLoader, + Substitute.For(), + startupEnvironmentService, + runtimeContext, + startupDialogService); + InitWindowStartupCompletedEventArgs? completedArgs = null; + viewModel.StartupCompleted += (_, args) => completedArgs = args; + + // Act + await viewModel.StartAsync(); + + // Assert + completedArgs.Should().NotBeNull(); + completedArgs!.Connected.Should().BeFalse(); + startupDialogService.ThemedTitle.Should().Be("Information"); + startupDialogService.ThemedMessage.Should().Be("Cannot connect"); + startupDialogService.ThemedColors.Should().BeSameAs(runtimeContext.Colors); + } + + [Fact] + public async Task PrepareLauncherAsync_WhenRecoveryRetrySucceeds_ContinuesStartupAsync() + { + // Arrange + ILaunchPreparationService launchPreparationService = Substitute.For(); + launchPreparationService.RecoverAsync(Arg.Any(), Arg.Any()) + .Returns( + LaunchPreparationResult.Failure(Array.Empty()), + LaunchPreparationResult.Success()); + IRemoteConnectionProbe connectionProbe = Substitute.For(); + connectionProbe.CanConnectAsync(Arg.Any(), Arg.Any()) + .Returns(false); + ILauncherContentCatalogLoader catalogLoader = Substitute.For(); + catalogLoader.InitDataAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.CompletedTask); + ILauncherStartupEnvironmentService startupEnvironmentService = + Substitute.For(); + startupEnvironmentService.ReadAsync(Arg.Any(), Arg.Any()) + .Returns(new LauncherStartupEnvironment( + SupportedGame.ZeroHour, + CreateColors(), + null)); + FakeStartupDialogService startupDialogService = new(); + InitWindowViewModel viewModel = CreateViewModel( + Substitute.For(), + launchPreparationService, + connectionProbe, + catalogLoader, + Substitute.For(), + startupEnvironmentService, + new LauncherRuntimeContext(CreateLauncherPaths(), "1.2.3"), + startupDialogService); + + // Act + bool connected = await viewModel.PrepareLauncherAsync(); + + // Assert + connected.Should().BeFalse(); + startupDialogService.RetryCancelWarningCount.Should().Be(1); + await startupEnvironmentService.Received(1) + .ReadAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task PrepareLauncherAsync_WhenConnectedWithSelectedMod_LoadsSelectedModChildrenAsync() + { + // Arrange + ILaunchPreparationService launchPreparationService = Substitute.For(); + launchPreparationService.RecoverAsync(Arg.Any(), Arg.Any()) + .Returns(LaunchPreparationResult.Success()); + IRemoteConnectionProbe connectionProbe = Substitute.For(); + connectionProbe.CanConnectAsync(Arg.Any(), Arg.Any()) + .Returns(true); + ILauncherContentCatalogLoader catalogLoader = Substitute.For(); + catalogLoader.ReposModsNames.Returns(new[] { "Contra" }); + catalogLoader.InitDataAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.CompletedTask); + GameModification selectedMod = new() + { + Name = "Contra", + Version = "1.0", + ModificationType = ModificationType.Mod + }; + ILauncherContentCatalogQueries catalogQueries = Substitute.For(); + catalogQueries.GetSelectedMod().Returns(selectedMod); + ILauncherStartupEnvironmentService startupEnvironmentService = + Substitute.For(); + startupEnvironmentService.ReadAsync(Arg.Any(), Arg.Any()) + .Returns(new LauncherStartupEnvironment( + SupportedGame.ZeroHour, + CreateColors(), + null)); + InitWindowViewModel viewModel = CreateViewModel( + Substitute.For(), + launchPreparationService, + connectionProbe, + catalogLoader, + catalogQueries, + startupEnvironmentService, + new LauncherRuntimeContext(CreateLauncherPaths(), "1.2.3"), + new FakeStartupDialogService()); + + // Act + bool connected = await viewModel.PrepareLauncherAsync(); + + // Assert + connected.Should().BeTrue(); + await catalogLoader.Received(1) + .ReadPatchesAndAddonsForModAsync(selectedMod, Arg.Any()); + } + + [Fact] + public async Task PrepareLauncherAsync_WhenCatalogNamesAreMissing_TreatsStartupAsOfflineAsync() + { + // Arrange + ILaunchPreparationService launchPreparationService = Substitute.For(); + launchPreparationService.RecoverAsync(Arg.Any(), Arg.Any()) + .Returns(LaunchPreparationResult.Success()); + IRemoteConnectionProbe connectionProbe = Substitute.For(); + connectionProbe.CanConnectAsync(Arg.Any(), Arg.Any()) + .Returns(true); + ILauncherContentCatalogLoader catalogLoader = Substitute.For(); + catalogLoader.ReposModsNames.Returns((IReadOnlyList?)null); + catalogLoader.InitDataAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.CompletedTask); + ILauncherStartupEnvironmentService startupEnvironmentService = + Substitute.For(); + startupEnvironmentService.ReadAsync(Arg.Any(), Arg.Any()) + .Returns(new LauncherStartupEnvironment( + SupportedGame.ZeroHour, + CreateColors(), + null)); + FakeStartupDialogService startupDialogService = new(); + InitWindowViewModel viewModel = CreateViewModel( + Substitute.For(), + launchPreparationService, + connectionProbe, + catalogLoader, + Substitute.For(), + startupEnvironmentService, + new LauncherRuntimeContext(CreateLauncherPaths(), "1.2.3"), + startupDialogService); + + // Act + bool connected = await viewModel.PrepareLauncherAsync(); + + // Assert + connected.Should().BeFalse(); + startupDialogService.ThemedTitle.Should().Be("Information"); + startupDialogService.ThemedMessage.Should().Be("Cannot connect"); + } + + private static InitWindowViewModel CreateViewModel( + ILauncherPathResolver pathResolver, + ILaunchPreparationService launchPreparationService, + IRemoteConnectionProbe connectionProbe, + ILauncherContentCatalogLoader catalogLoader, + ILauncherContentCatalogQueries catalogQueries, + ILauncherStartupEnvironmentService startupEnvironmentService, + LauncherRuntimeContext runtimeContext, + IStartupDialogService startupDialogService) + { + return new InitWindowViewModel( + connectionProbe, + launchPreparationService, + pathResolver, + catalogLoader, + catalogQueries, + startupEnvironmentService, + new LauncherContentLayout("Addons", "Patches"), + runtimeContext, + new TestStringLocalizer(new Dictionary + { + ["CannotConnect"] = "Cannot connect", + ["DeploymentRecoveryFailed"] = "Deployment recovery failed", + ["Info"] = "Information", + }), + startupDialogService); + } + + private static LauncherPaths CreateLauncherPaths() + { + return new LauncherPaths( + @"C:\Games\ZeroHour", + @"C:\Games\ZeroHour\GenLauncherGO", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Cache\Images", + @"C:\Games\ZeroHour\GenLauncherGO\Mods", + @"C:\Games\ZeroHour\GenLauncherGO\Logs", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Temp", + @"C:\Games\ZeroHour\GenLauncherGO\Runtime\Deployment"); + } + + private static ColorsInfoString CreateColors() + { + return new ColorsInfoString( + "#00e3ff", + "DarkGray", + "#7a7db0", + "#baff0c", + "#232977", + "#090502", + "#B3000000", + "White", + "#090502", + "#F21d2057", + "#F21d2057", + "#2534ff"); + } + + private sealed class FakeStartupDialogService : IStartupDialogService + { + private readonly bool _retryRecovery; + + public FakeStartupDialogService(bool retryRecovery = true) + { + _retryRecovery = retryRecovery; + } + + public string? ThemedTitle { get; private set; } + + public string? ThemedMessage { get; private set; } + + public ColorsInfo? ThemedColors { get; private set; } + + public int RetryCancelWarningCount { get; private set; } + + public void ShowMessage(string message) + { + } + + public void ShowThemedMessage(string title, string message, ColorsInfo colors) + { + ThemedTitle = title; + ThemedMessage = message; + ThemedColors = colors; + } + + public bool ShowRetryCancelWarning(string title, string message) + { + RetryCancelWarningCount++; + return _retryRecovery; + } + } +} diff --git a/GenLauncherGO.Tests/UI/Shared/Commands/AsyncRelayCommandTests.cs b/GenLauncherGO.Tests/UI/Shared/Commands/AsyncRelayCommandTests.cs new file mode 100644 index 00000000..c2331ce1 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Shared/Commands/AsyncRelayCommandTests.cs @@ -0,0 +1,62 @@ +using System; +using System.Threading.Tasks; +using GenLauncherGO.UI.Shared.Commands; + +namespace GenLauncherGO.Tests.UI.Shared.Commands; + +public sealed class AsyncRelayCommandTests +{ + [Fact] + public async Task Execute_RunsAsynchronousActionAsync() + { + // Arrange + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var command = new AsyncRelayCommand(_ => + { + completion.SetResult(); + return Task.CompletedTask; + }); + + // Act + command.Execute(null); + + // Assert + await completion.Task.WaitAsync(TimeSpan.FromSeconds(5)); + } + + [Fact] + public void CanExecute_ReturnsFalseWhileCommandIsExecuting() + { + // Arrange + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var command = new AsyncRelayCommand(_ => completion.Task); + + // Act + command.Execute(null); + + // Assert + command.CanExecute(null).Should().BeFalse(); + + completion.SetResult(); + } + + [Fact] + public void Execute_ReturnsWhenCommandCannotExecute() + { + // Arrange + bool executed = false; + var command = new AsyncRelayCommand( + _ => + { + executed = true; + return Task.CompletedTask; + }, + _ => false); + + // Act + command.Execute(null); + + // Assert + executed.Should().BeFalse(); + } +} diff --git a/GenLauncherGO.Tests/UI/Shared/Commands/RelayCommandTests.cs b/GenLauncherGO.Tests/UI/Shared/Commands/RelayCommandTests.cs new file mode 100644 index 00000000..48dd1a6a --- /dev/null +++ b/GenLauncherGO.Tests/UI/Shared/Commands/RelayCommandTests.cs @@ -0,0 +1,52 @@ +using System; +using GenLauncherGO.UI.Shared.Commands; + +namespace GenLauncherGO.Tests.UI.Shared.Commands; + +public sealed class RelayCommandTests +{ + [Fact] + public void CanExecuteUsesPredicateWhenProvided() + { + // Arrange + RelayCommand command = new(_ => { }, parameter => parameter is string value && value.Length > 3); + + // Act and Assert + command.CanExecute("test").Should().BeTrue(); + command.CanExecute("no").Should().BeFalse(); + } + + [Fact] + public void CanExecuteReturnsTrueWhenPredicateIsNotProvided() + { + // Arrange + RelayCommand command = new(_ => { }); + + // Act and Assert + command.CanExecute(null).Should().BeTrue(); + } + + [Fact] + public void ExecutePassesParameterToAction() + { + // Arrange + object? capturedParameter = null; + RelayCommand command = new(parameter => capturedParameter = parameter); + + // Act + command.Execute("payload"); + + // Assert + capturedParameter.Should().Be("payload"); + } + + [Fact] + public void ConstructorThrowsWhenExecuteIsNull() + { + // Act + Action act = () => _ = new RelayCommand(null!); + + // Assert + act.Should().Throw().WithParameterName("execute"); + } +} diff --git a/GenLauncherGO.Tests/UI/Shared/Controls/UpdateButtonTests.cs b/GenLauncherGO.Tests/UI/Shared/Controls/UpdateButtonTests.cs new file mode 100644 index 00000000..4ee97136 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Shared/Controls/UpdateButtonTests.cs @@ -0,0 +1,59 @@ +using System; +using System.Windows; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Shared.Controls; + +namespace GenLauncherGO.Tests.UI.Shared.Controls; + +public sealed class UpdateButtonTests +{ + [Fact] + public void ConstructorInitializesDependencyPropertyDefaults() + { + StaTestRunner.Run(() => + { + // Act + UpdateButton button = new(); + + // Assert + button.IsBlinking.Should().BeFalse(); + button.BlinkCount.Should().Be(10); + }); + } + + [Fact] + public void DependencyPropertiesStoreAssignedValues() + { + StaTestRunner.Run(() => + { + // Arrange + UpdateButton button = new(); + + // Act + button.BlinkCount = 3; + button.IsBlinking = true; + button.IsBlinking = false; + + // Assert + button.BlinkCount.Should().Be(3); + button.IsBlinking.Should().BeFalse(); + }); + } + + [Fact] + public void RefreshUsesAvailableResourcesWithoutRequiringTemplate() + { + StaTestRunner.Run(() => + { + // Arrange + UpdateButton button = new(); + button.Resources["GenLauncherDarkFillColor"] = System.Windows.Media.Brushes.Red; + + // Act + Action act = button.Refresh; + + // Assert + act.Should().NotThrow(); + }); + } +} diff --git a/GenLauncherGO.Tests/UI/Shared/Formatting/PackageProgressTextFormatterTests.cs b/GenLauncherGO.Tests/UI/Shared/Formatting/PackageProgressTextFormatterTests.cs new file mode 100644 index 00000000..514f6214 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Shared/Formatting/PackageProgressTextFormatterTests.cs @@ -0,0 +1,119 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Shared.Formatting; + +namespace GenLauncherGO.Tests.UI.Shared.Formatting; + +public sealed class PackageProgressTextFormatterTests +{ + [Fact] + public void TryFormat_ReturnsFalseWhenProgressHasNoPercentage() + { + // Arrange + PackageUpdateProgress progress = new(null, 0, null, null); + + // Act + bool formatted = PackageProgressTextFormatter.TryFormat( + progress, + new TestStringLocalizer(), + out string message, + out int percentage); + + // Assert + formatted.Should().BeFalse(); + message.Should().BeEmpty(); + percentage.Should().Be(0); + } + + [Fact] + public void TryFormat_FormatsBytesSpeedAndShortEta() + { + // Arrange + PackageUpdateProgress progress = new( + 2_097_152, + 1_048_576, + 50, + "package.big", + 1_048_576, + TimeSpan.FromSeconds(90)); + TestStringLocalizer localizer = new(new Dictionary + { + ["DownloadInProgress"] = "{0} of {1} MB", + ["UnpackingPreparing"] = "Preparing", + }); + + // Act + bool formatted = PackageProgressTextFormatter.TryFormat( + progress, + localizer, + out string message, + out int percentage); + + // Assert + formatted.Should().BeTrue(); + message.Should().Be("1.0 of 2.0 MB - 1.0 MB/s - ETA 01:30"); + percentage.Should().Be(50); + } + + [Fact] + public void TryFormat_FormatsLongEtaWithHours() + { + // Arrange + PackageUpdateProgress progress = new( + 4_194_304, + 1_048_576, + 25, + null, + null, + new TimeSpan(1, 2, 3)); + TestStringLocalizer localizer = new(new Dictionary + { + ["DownloadInProgress"] = "{0}/{1}", + ["UnpackingPreparing"] = "Preparing", + }); + + // Act + bool formatted = PackageProgressTextFormatter.TryFormat( + progress, + localizer, + out string message, + out int percentage); + + // Assert + formatted.Should().BeTrue(); + message.Should().Be("1.0/4.0 - ETA 1:02:03"); + percentage.Should().Be(25); + } + + [Fact] + public void TryFormat_UsesPreparingTextAtOneHundredPercent() + { + // Arrange + PackageUpdateProgress progress = new( + 1_048_576, + 1_048_576, + 100, + null, + 1_048_576, + TimeSpan.Zero); + TestStringLocalizer localizer = new(new Dictionary + { + ["DownloadInProgress"] = "{0}/{1}", + ["UnpackingPreparing"] = "Preparing files", + }); + + // Act + bool formatted = PackageProgressTextFormatter.TryFormat( + progress, + localizer, + out string message, + out int percentage); + + // Assert + formatted.Should().BeTrue(); + message.Should().Be("Preparing files"); + percentage.Should().Be(100); + } +} diff --git a/GenLauncherGO.Tests/UI/Shared/Localization/WpfLauncherStringLocalizerTests.cs b/GenLauncherGO.Tests/UI/Shared/Localization/WpfLauncherStringLocalizerTests.cs new file mode 100644 index 00000000..03eb3417 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Shared/Localization/WpfLauncherStringLocalizerTests.cs @@ -0,0 +1,36 @@ +using System.Globalization; +using GenLauncherGO.UI.Shared.Localization; + +namespace GenLauncherGO.Tests.UI.Shared.Localization; + +[Collection("WpfLocalization")] +public sealed class WpfLauncherStringLocalizerTests +{ + [Fact] + public void Indexer_WithNeutralCulture_ReturnsStringFromUiResources() + { + // Arrange + WpfLauncherStringLocalizer localizer = new(); + + // Act + localizer.SetCulture(CultureInfo.GetCultureInfo("en")); + string result = localizer["Update"]; + + // Assert + result.Should().Be("Update!"); + } + + [Fact] + public void Indexer_WithSatelliteCulture_ReturnsStringFromUiResources() + { + // Arrange + WpfLauncherStringLocalizer localizer = new(); + + // Act + localizer.SetCulture(CultureInfo.GetCultureInfo("ru")); + string result = localizer["Update"]; + + // Assert + result.Should().Be("\u041E\u0411\u041D\u041E\u0412\u0418\u0422\u042C!"); + } +} diff --git a/GenLauncherGO.Tests/UI/Shared/Themes/LauncherThemeResourceApplierTests.cs b/GenLauncherGO.Tests/UI/Shared/Themes/LauncherThemeResourceApplierTests.cs new file mode 100644 index 00000000..49dc9cea --- /dev/null +++ b/GenLauncherGO.Tests/UI/Shared/Themes/LauncherThemeResourceApplierTests.cs @@ -0,0 +1,104 @@ +using System.Windows; +using System.Windows.Media; +using GenLauncherGO.Tests.Testing; +using GenLauncherGO.UI.Shared.Themes; + +namespace GenLauncherGO.Tests.UI.Shared.Themes; + +public sealed class LauncherThemeResourceApplierTests +{ + [Fact] + public void ApplyPopulatesThemeResourcesAndBrushes() + { + StaTestRunner.Run(() => + { + // Arrange + ResourceDictionary resources = new(); + ColorsInfo colors = CreateColors(); + ImageBrush background = new(); + colors.GenLauncherBackgroundImage = background; + + // Act + LauncherThemeResourceApplier.Apply(resources, colors); + + // Assert + resources["GenLauncherBorderColor"].Should().BeSameAs(colors.GenLauncherBorderColor); + resources["GenLauncherActiveColor"].Should().BeSameAs(colors.GenLauncherActiveColor); + resources["GenLauncherDarkFillColor"].Should().BeSameAs(colors.GenLauncherDarkFillColor); + resources["GenLauncherInactiveBorder"].Should().BeSameAs(colors.GenLauncherInactiveBorder); + resources["GenLauncherInactiveBorder2"].Should().BeSameAs(colors.GenLauncherInactiveBorder2); + resources["GenLauncherDefaultTextColor"].Should().BeSameAs(colors.GenLauncherDefaultTextColor); + resources["GenLauncherLightBackGround"].Should().BeSameAs(colors.GenLauncherLightBackGround); + resources["GenLauncherDarkBackGround"].Should().BeSameAs(colors.GenLauncherDarkBackGround); + resources["GenLauncherListBoxSelectionColor2"].Should().Be(colors.GenLauncherListBoxSelectionColor2); + resources["GenLauncherListBoxSelectionColor1"].Should().Be(colors.GenLauncherListBoxSelectionColor1); + resources["GenLauncherButtonSelectionColor"].Should().Be(colors.GenLauncherButtonSelectionColor); + resources["GenLauncherBackGroundImage"].Should().BeSameAs(background); + + resources["ButtonPressedBackground"].Should().BeOfType() + .Which.GradientStops.Should().HaveCount(4); + resources["ListBoxSelectedItemBackground"].Should().BeOfType() + .Which.GradientStops.Should().HaveCount(3); + resources["DialogListBoxSelectedItemBackground"].Should().BeOfType() + .Which.GradientStops.Should().HaveCount(2); + resources["ListBoxMouseOverItemBackground"].Should().BeOfType() + .Which.GradientStops.Should().HaveCount(3); + }); + } + + [Fact] + public void ApplyWhenBackgroundImageIsExcludedKeepsExistingBackgroundResource() + { + StaTestRunner.Run(() => + { + // Arrange + ResourceDictionary resources = new() + { + ["GenLauncherBackGroundImage"] = "existing" + }; + ColorsInfo colors = CreateColors(); + colors.GenLauncherBackgroundImage = new ImageBrush(); + + // Act + LauncherThemeResourceApplier.Apply(resources, colors, includeBackgroundImage: false); + + // Assert + resources["GenLauncherBackGroundImage"].Should().Be("existing"); + }); + } + + [Fact] + public void ApplyToFrameworkElementPopulatesElementResources() + { + StaTestRunner.Run(() => + { + // Arrange + FrameworkElement element = new(); + ColorsInfo colors = CreateColors(); + + // Act + LauncherThemeResourceApplier.Apply(element, colors); + + // Assert + element.Resources["GenLauncherBorderColor"].Should().BeSameAs(colors.GenLauncherBorderColor); + element.Resources["ButtonPressedBackground"].Should().BeOfType(); + }); + } + + private static ColorsInfo CreateColors() + { + return new ColorsInfo( + "#FF010203", + "#FF111213", + "#FF212223", + "#FF313233", + "#FF414243", + "#FF515253", + "#FF616263", + "#FF717273", + "#FF818283", + "#FF919293", + "#FFA1A2A3", + "#FFB1B2B3"); + } +} diff --git a/GenLauncherGO.UI/AGENTS.md b/GenLauncherGO.UI/AGENTS.md new file mode 100644 index 00000000..6672f75f --- /dev/null +++ b/GenLauncherGO.UI/AGENTS.md @@ -0,0 +1,27 @@ +# GenLauncherGO.UI Agent Guidelines + +`GenLauncherGO.UI/` owns WPF presentation code and application composition. + +## Do + +- Keep `GenLauncherGO.UI` as the dependency injection composition root. +- Put windows, dialogs, controls, view models, commands, resources, and themes in UI. +- Use constructor injection for view models and services. +- Localize new or changed user-visible strings in the same change. +- Keep the neutral resource file and satellite resource files structurally in sync. +- Use an English fallback value when a fluent translation is not available yet and mention that in the change summary. +- Reuse existing resource keys when meaning is unchanged; create new keys when meaning changes. +- Use stable resource key names that describe UI meaning rather than current wording. +- Add XML documentation for every production type and member, regardless of accessibility. +- Use `ILogger` for startup, command, and user-flow diagnostics when failures need troubleshooting context. +- Prefer `Features/`, `Shared/Controls/`, `Shared/Resources/`, and `Shared/Themes/`. +- Inside a crowded `Features//` folder, layer by real responsibilities such as `ViewModels/`, `Commands/`, + `Views/`, `Dialogs/`, and `Resources/`. Keep simple features flat. + +## Avoid + +- Do not put concrete file-system mutation, process launching, GitHub/S3 access, archive extraction, hashing, or + symbolic-link implementation in UI. +- Do not hard-code user-visible strings in XAML or code-behind unless the string is diagnostic-only and not shown to + users. +- Do not edit generated localization designer files by hand when the project tooling can regenerate them. diff --git a/GenLauncherGO.UI/Features/Dialogs/Composition/DialogServiceCollectionExtensions.cs b/GenLauncherGO.UI/Features/Dialogs/Composition/DialogServiceCollectionExtensions.cs new file mode 100644 index 00000000..d20d1b02 --- /dev/null +++ b/GenLauncherGO.UI/Features/Dialogs/Composition/DialogServiceCollectionExtensions.cs @@ -0,0 +1,30 @@ +using System; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.UI.Features.Dialogs.Composition; + +/// +/// Provides dependency-injection registrations for launcher dialog services. +/// +internal static class DialogServiceCollectionExtensions +{ + /// + /// Registers launcher dialog services. + /// + /// The service collection used by the application composition root. + /// The same service collection so additional registrations can be chained. + /// + /// Thrown when is . + /// + public static IServiceCollection AddGenLauncherGoDialogs(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/GenLauncherGO.UI/Features/Dialogs/Contracts/ILauncherDialogService.cs b/GenLauncherGO.UI/Features/Dialogs/Contracts/ILauncherDialogService.cs new file mode 100644 index 00000000..e3e013fe --- /dev/null +++ b/GenLauncherGO.UI/Features/Dialogs/Contracts/ILauncherDialogService.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using System.Windows; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.UI.Features.Dialogs.Models; +using GenLauncherGO.UI.Features.Integrity; + +namespace GenLauncherGO.UI.Features.Dialogs.Contracts; + +/// +/// Shows launcher-owned modal dialogs and returns user choices to callers. +/// +public interface ILauncherDialogService +{ + /// + /// Shows a themed information dialog. + /// + /// The dialog text and display options. + /// The owner window, when one should be assigned. + void ShowInfo(LauncherInfoDialogRequest request, Window? owner = null); + + /// + /// Shows a themed error dialog. + /// + /// The dialog text and display options. + /// The owner window, when one should be assigned. + void ShowError(LauncherInfoDialogRequest request, Window? owner = null); + + /// + /// Shows a themed warning confirmation dialog. + /// + /// The dialog text and display options. + /// The optional replacement text for the continue button. + /// The owner window, when one should be assigned. + /// when the user chose to continue. + bool ShowWarningConfirmation( + LauncherInfoDialogRequest request, + string? continueText = null, + Window? owner = null); + + /// + /// Shows the repository modification selection dialog. + /// + /// The modification names the user may add. + /// The owner window, when one should be assigned. + /// The selected modification name, or when the dialog was canceled. + string? ShowModificationSelection(IReadOnlyList modificationNames, Window? owner = null); + + /// + /// Shows the manual import details dialog. + /// + /// The selected files and optional parent content name. + /// The owner window, when one should be assigned. + /// The entered import details, or when the dialog was canceled. + ManualModificationDialogResult? ShowManualModificationImport( + ManualModificationDialogRequest request, + Window? owner = null); + + /// + /// Shows the integrity review dialog. + /// + /// The report to review. + /// The localized dialog options. + /// The owner window, when one should be assigned. + /// when the user confirmed the offered resolution. + bool ShowIntegrityReview( + ContentIntegrityReport report, + IntegrityReviewDialogOptions options, + Window? owner = null); +} diff --git a/GenLauncherGO.UI/Features/Dialogs/Contracts/ILauncherDialogWindowFactory.cs b/GenLauncherGO.UI/Features/Dialogs/Contracts/ILauncherDialogWindowFactory.cs new file mode 100644 index 00000000..f3aa49fb --- /dev/null +++ b/GenLauncherGO.UI/Features/Dialogs/Contracts/ILauncherDialogWindowFactory.cs @@ -0,0 +1,53 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.UI.Features.Dialogs.Models; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Mods.ViewModels; +using GenLauncherGO.UI.Features.Mods.Views; + +namespace GenLauncherGO.UI.Features.Dialogs.Contracts; + +/// +/// Creates WPF dialog windows with their view models and launcher theme resources. +/// +internal interface ILauncherDialogWindowFactory +{ + /// + /// Creates a repository modification selection dialog. + /// + /// The available repository modification names. + /// The configured selection dialog. + AddModificationWindow CreateAddModificationWindow(IReadOnlyList modificationNames); + + /// + /// Creates an integrity review dialog. + /// + /// The integrity report to display. + /// The localized review dialog options. + /// The configured integrity review dialog. + IntegrityReviewDialog CreateIntegrityReviewDialog( + ContentIntegrityReport report, + IntegrityReviewDialogOptions options); + + /// + /// Creates a themed information or confirmation dialog. + /// + /// The dialog text and display options. + /// The dialog kind to display. + /// The optional replacement continue button text. + /// The configured information dialog. + InfoWindow CreateInfoWindow( + LauncherInfoDialogRequest request, + InfoDialogKind kind, + string? continueText = null); + + /// + /// Creates a manual modification import dialog. + /// + /// The import dialog request. + /// The dialog service used by the dialog view model for validation errors. + /// The configured manual import dialog. + ManualAddModificationWindow CreateManualModificationWindow( + ManualModificationDialogRequest request, + ILauncherDialogService dialogService); +} diff --git a/GenLauncherGO.UI/Features/Dialogs/Models/LauncherInfoDialogRequest.cs b/GenLauncherGO.UI/Features/Dialogs/Models/LauncherInfoDialogRequest.cs new file mode 100644 index 00000000..e4d4a836 --- /dev/null +++ b/GenLauncherGO.UI/Features/Dialogs/Models/LauncherInfoDialogRequest.cs @@ -0,0 +1,40 @@ +using System; + +namespace GenLauncherGO.UI.Features.Dialogs.Models; + +/// +/// Describes text and display options for a launcher information dialog. +/// +public sealed class LauncherInfoDialogRequest +{ + /// + /// Initializes a new instance of the class. + /// + /// The primary message text. + /// The secondary message text. + /// The optional secondary message font size. + public LauncherInfoDialogRequest( + string mainMessage, + string detailMessage, + double? detailFontSize = null) + { + MainMessage = mainMessage ?? throw new ArgumentNullException(nameof(mainMessage)); + DetailMessage = detailMessage ?? throw new ArgumentNullException(nameof(detailMessage)); + DetailFontSize = detailFontSize; + } + + /// + /// Gets the primary message text. + /// + public string MainMessage { get; } + + /// + /// Gets the secondary message text. + /// + public string DetailMessage { get; } + + /// + /// Gets the optional secondary message font size. + /// + public double? DetailFontSize { get; } +} diff --git a/GenLauncherGO.UI/Features/Dialogs/Models/ManualModificationDialogRequest.cs b/GenLauncherGO.UI/Features/Dialogs/Models/ManualModificationDialogRequest.cs new file mode 100644 index 00000000..97d753be --- /dev/null +++ b/GenLauncherGO.UI/Features/Dialogs/Models/ManualModificationDialogRequest.cs @@ -0,0 +1,36 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GenLauncherGO.UI.Features.Dialogs.Models; + +/// +/// Describes a manual content import dialog request. +/// +public sealed class ManualModificationDialogRequest +{ + /// + /// Initializes a new instance of the class. + /// + /// The selected package files. + /// The parent modification name for patch or addon imports. + public ManualModificationDialogRequest( + IReadOnlyList files, + string? parentContentName = null) + { + ArgumentNullException.ThrowIfNull(files); + + Files = files.ToList(); + ParentContentName = parentContentName; + } + + /// + /// Gets the selected package files. + /// + public IReadOnlyList Files { get; } + + /// + /// Gets the parent modification name for patch or addon imports. + /// + public string? ParentContentName { get; } +} diff --git a/GenLauncherGO.UI/Features/Dialogs/Models/ManualModificationDialogResult.cs b/GenLauncherGO.UI/Features/Dialogs/Models/ManualModificationDialogResult.cs new file mode 100644 index 00000000..1dc0099c --- /dev/null +++ b/GenLauncherGO.UI/Features/Dialogs/Models/ManualModificationDialogResult.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GenLauncherGO.UI.Features.Dialogs.Models; + +/// +/// Stores user-entered manual content import details. +/// +public sealed class ManualModificationDialogResult +{ + /// + /// Initializes a new instance of the class. + /// + /// The selected package files. + /// The parent modification name for patch or addon imports. + /// The entered modification, patch, or addon name. + /// The entered version. + public ManualModificationDialogResult( + IReadOnlyList files, + string? parentContentName, + string modificationName, + string version) + { + ArgumentNullException.ThrowIfNull(files); + + Files = files.ToList(); + ParentContentName = parentContentName; + ModificationName = modificationName ?? throw new ArgumentNullException(nameof(modificationName)); + Version = version ?? throw new ArgumentNullException(nameof(version)); + } + + /// + /// Gets the selected package files. + /// + public IReadOnlyList Files { get; } + + /// + /// Gets the parent modification name for patch or addon imports. + /// + public string? ParentContentName { get; } + + /// + /// Gets the entered modification, patch, or addon name. + /// + public string ModificationName { get; } + + /// + /// Gets the entered version. + /// + public string Version { get; } +} diff --git a/GenLauncherGO.UI/Features/Dialogs/Services/LauncherDialogViewModelFactory.cs b/GenLauncherGO.UI/Features/Dialogs/Services/LauncherDialogViewModelFactory.cs new file mode 100644 index 00000000..060db33a --- /dev/null +++ b/GenLauncherGO.UI/Features/Dialogs/Services/LauncherDialogViewModelFactory.cs @@ -0,0 +1,97 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Models; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Integrity.ViewModels; +using GenLauncherGO.UI.Features.Mods.ViewModels; +using GenLauncherGO.UI.Shared.Localization; + +namespace GenLauncherGO.UI.Features.Dialogs.Services; + +/// +/// Creates runtime dialog view models for launcher dialog windows. +/// +internal sealed class LauncherDialogViewModelFactory +{ + /// + /// The localized string provider. + /// + private readonly ILauncherStringLocalizer _stringLocalizer; + + /// + /// Initializes a new instance of the class. + /// + /// The localized string provider. + public LauncherDialogViewModelFactory(ILauncherStringLocalizer stringLocalizer) + { + _stringLocalizer = stringLocalizer ?? throw new ArgumentNullException(nameof(stringLocalizer)); + } + + /// + /// Creates a repository modification selection dialog view model. + /// + /// The available repository modification names. + /// The created dialog view model. + public AddModificationViewModel CreateAddModificationViewModel(IReadOnlyList modificationNames) + { + ArgumentNullException.ThrowIfNull(modificationNames); + + return new AddModificationViewModel(modificationNames); + } + + /// + /// Creates an integrity review dialog view model. + /// + /// The integrity report to review. + /// The dialog behavior options. + /// The created dialog view model. + public IntegrityReviewViewModel CreateIntegrityReviewViewModel( + ContentIntegrityReport report, + IntegrityReviewDialogOptions options) + { + ArgumentNullException.ThrowIfNull(report); + ArgumentNullException.ThrowIfNull(options); + + return new IntegrityReviewViewModel(report, options); + } + + /// + /// Creates an information dialog view model. + /// + /// The dialog request. + /// The dialog behavior kind. + /// The optional continue button text. + /// The created dialog view model. + public InfoDialogViewModel CreateInfoDialogViewModel( + LauncherInfoDialogRequest request, + InfoDialogKind kind, + string? continueText) + { + ArgumentNullException.ThrowIfNull(request); + + return new InfoDialogViewModel(request, kind, continueText); + } + + /// + /// Creates a manual modification import dialog view model. + /// + /// The manual import request. + /// The dialog service used for validation messages. + /// The created dialog view model. + public ManualAddModificationViewModel CreateManualModificationViewModel( + ManualModificationDialogRequest request, + ILauncherDialogService dialogService) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(dialogService); + + return new ManualAddModificationViewModel( + request.Files.ToList(), + request.ParentContentName, + _stringLocalizer, + dialogService); + } +} diff --git a/GenLauncherGO.UI/Features/Dialogs/Services/LauncherDialogWindowFactory.cs b/GenLauncherGO.UI/Features/Dialogs/Services/LauncherDialogWindowFactory.cs new file mode 100644 index 00000000..27a7700e --- /dev/null +++ b/GenLauncherGO.UI/Features/Dialogs/Services/LauncherDialogWindowFactory.cs @@ -0,0 +1,90 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Models; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Mods.Contracts; +using GenLauncherGO.UI.Features.Mods.ViewModels; +using GenLauncherGO.UI.Features.Mods.Views; +using GenLauncherGO.UI.Shared.Themes; + +namespace GenLauncherGO.UI.Features.Dialogs.Services; + +/// +/// Creates themed WPF dialog windows with their runtime view models. +/// +internal sealed class LauncherDialogWindowFactory : ILauncherDialogWindowFactory +{ + /// + /// The current launcher UI context. + /// + private readonly ILauncherModsContext _launcherContext; + + /// + /// The factory that creates dialog view models. + /// + private readonly LauncherDialogViewModelFactory _viewModelFactory; + + /// + /// Initializes a new instance of the class. + /// + /// The current launcher UI context. + /// The factory that creates dialog view models. + public LauncherDialogWindowFactory( + ILauncherModsContext launcherContext, + LauncherDialogViewModelFactory viewModelFactory) + { + _launcherContext = launcherContext ?? throw new ArgumentNullException(nameof(launcherContext)); + _viewModelFactory = viewModelFactory ?? throw new ArgumentNullException(nameof(viewModelFactory)); + } + + /// + public AddModificationWindow CreateAddModificationWindow(IReadOnlyList modificationNames) + { + ArgumentNullException.ThrowIfNull(modificationNames); + + return new AddModificationWindow( + _viewModelFactory.CreateAddModificationViewModel(modificationNames), + _launcherContext.Colors); + } + + /// + public IntegrityReviewDialog CreateIntegrityReviewDialog( + ContentIntegrityReport report, + IntegrityReviewDialogOptions options) + { + ArgumentNullException.ThrowIfNull(report); + ArgumentNullException.ThrowIfNull(options); + + IntegrityReviewDialog dialog = new(_viewModelFactory.CreateIntegrityReviewViewModel(report, options)); + LauncherThemeResourceApplier.Apply(dialog, _launcherContext.Colors, includeBackgroundImage: false); + return dialog; + } + + /// + public InfoWindow CreateInfoWindow( + LauncherInfoDialogRequest request, + InfoDialogKind kind, + string? continueText = null) + { + ArgumentNullException.ThrowIfNull(request); + + return new InfoWindow( + _viewModelFactory.CreateInfoDialogViewModel(request, kind, continueText), + _launcherContext.Colors); + } + + /// + public ManualAddModificationWindow CreateManualModificationWindow( + ManualModificationDialogRequest request, + ILauncherDialogService dialogService) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(dialogService); + + return new ManualAddModificationWindow( + _viewModelFactory.CreateManualModificationViewModel(request, dialogService), + _launcherContext.Colors); + } +} diff --git a/GenLauncherGO.UI/Features/Dialogs/Services/WpfLauncherDialogService.cs b/GenLauncherGO.UI/Features/Dialogs/Services/WpfLauncherDialogService.cs new file mode 100644 index 00000000..20bdd8f6 --- /dev/null +++ b/GenLauncherGO.UI/Features/Dialogs/Services/WpfLauncherDialogService.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.Windows; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Models; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Mods.ViewModels; +using GenLauncherGO.UI.Features.Mods.Views; +using GenLauncherGO.UI.Shared.Localization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.UI.Features.Dialogs.Services; + +/// +/// Shows launcher dialogs with WPF windows. +/// +internal sealed class WpfLauncherDialogService : ILauncherDialogService +{ + /// + /// The factory that creates themed WPF dialog windows. + /// + private readonly ILauncherDialogWindowFactory _dialogWindowFactory; + + /// + /// The localized string provider. + /// + private readonly ILauncherStringLocalizer _stringLocalizer; + + /// + /// The logger used for dialog workflow diagnostics. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The factory that creates themed WPF dialog windows. + /// The localized string provider. + /// The logger used for dialog workflow diagnostics. + public WpfLauncherDialogService( + ILauncherDialogWindowFactory dialogWindowFactory, + ILauncherStringLocalizer stringLocalizer, + ILogger? logger = null) + { + _dialogWindowFactory = dialogWindowFactory ?? throw new ArgumentNullException(nameof(dialogWindowFactory)); + _stringLocalizer = stringLocalizer ?? throw new ArgumentNullException(nameof(stringLocalizer)); + _logger = logger ?? NullLogger.Instance; + } + + /// + public void ShowInfo(LauncherInfoDialogRequest request, Window? owner = null) + { + ArgumentNullException.ThrowIfNull(request); + + InfoWindow dialog = CreateInfoWindow(request, InfoDialogKind.Info, owner); + _logger.LogDebug("Showing launcher info dialog {DialogTitle}.", request.MainMessage); + dialog.ShowDialog(); + } + + /// + public void ShowError(LauncherInfoDialogRequest request, Window? owner = null) + { + ArgumentNullException.ThrowIfNull(request); + + InfoWindow dialog = CreateInfoWindow(request, InfoDialogKind.Error, owner); + _logger.LogWarning("Showing launcher error dialog {DialogTitle}.", request.MainMessage); + dialog.ShowDialog(); + } + + /// + public bool ShowWarningConfirmation( + LauncherInfoDialogRequest request, + string? continueText = null, + Window? owner = null) + { + ArgumentNullException.ThrowIfNull(request); + + InfoWindow dialog = CreateInfoWindow( + request, + InfoDialogKind.WarningConfirmation, + owner, + continueText ?? _stringLocalizer["Continue"]); + dialog.ShowDialog(); + bool confirmed = dialog.GetResult(); + _logger.LogInformation( + "Launcher warning confirmation {DialogTitle} completed with confirmed: {Confirmed}.", + request.MainMessage, + confirmed); + return confirmed; + } + + /// + public string? ShowModificationSelection(IReadOnlyList modificationNames, Window? owner = null) + { + ArgumentNullException.ThrowIfNull(modificationNames); + + AddModificationWindow dialog = _dialogWindowFactory.CreateAddModificationWindow(modificationNames); + ConfigureCenteredDialog(dialog, owner); + + if (dialog.ShowDialog() != true) + { + _logger.LogDebug( + "Repository modification selection dialog was canceled. Available options: {ModificationCount}.", + modificationNames.Count); + return null; + } + + _logger.LogInformation( + "Repository modification selection dialog selected {ModificationName}.", + dialog.SelectedModificationName); + return dialog.SelectedModificationName; + } + + /// + public ManualModificationDialogResult? ShowManualModificationImport( + ManualModificationDialogRequest request, + Window? owner = null) + { + ArgumentNullException.ThrowIfNull(request); + + ManualAddModificationWindow dialog = _dialogWindowFactory.CreateManualModificationWindow(request, this); + ConfigureCenteredDialog(dialog, owner); + + if (dialog.ShowDialog() != true) + { + _logger.LogDebug("Manual modification import dialog was canceled."); + return null; + } + + _logger.LogInformation("Manual modification import dialog produced an import request."); + return dialog.ImportResult; + } + + /// + public bool ShowIntegrityReview( + ContentIntegrityReport report, + IntegrityReviewDialogOptions options, + Window? owner = null) + { + ArgumentNullException.ThrowIfNull(report); + ArgumentNullException.ThrowIfNull(options); + + IntegrityReviewDialog dialog = _dialogWindowFactory.CreateIntegrityReviewDialog(report, options); + if (owner != null) + { + dialog.Owner = owner; + } + + dialog.ShowDialog(); + _logger.LogInformation( + "Integrity review dialog completed with confirmed: {Confirmed}; issues: {IssueCount}.", + dialog.ResolutionConfirmed, + report.Issues.Count); + return dialog.ResolutionConfirmed; + } + + /// + /// Creates the common information window. + /// + /// The dialog text and display options. + /// The dialog kind to display. + /// The owner window, when one should be assigned. + /// The optional replacement continue button text. + /// The configured dialog. + private InfoWindow CreateInfoWindow( + LauncherInfoDialogRequest request, + InfoDialogKind kind, + Window? owner, + string? continueText = null) + { + InfoWindow dialog = _dialogWindowFactory.CreateInfoWindow(request, kind, continueText); + ConfigureCenteredDialog(dialog, owner); + return dialog; + } + + /// + /// Applies shared modal window positioning. + /// + /// The dialog to configure. + /// The owner window, when one should be assigned. + private static void ConfigureCenteredDialog(Window dialog, Window? owner) + { + dialog.WindowStartupLocation = WindowStartupLocation.CenterScreen; + if (owner != null) + { + dialog.Owner = owner; + } + } +} diff --git a/GenLauncherGO.UI/Features/Integrity/ILaunchContentIntegrityProgressTarget.cs b/GenLauncherGO.UI/Features/Integrity/ILaunchContentIntegrityProgressTarget.cs new file mode 100644 index 00000000..736de8d1 --- /dev/null +++ b/GenLauncherGO.UI/Features/Integrity/ILaunchContentIntegrityProgressTarget.cs @@ -0,0 +1,37 @@ +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.UI.Features.Integrity; + +/// +/// Receives UI progress updates while launch content integrity issues are repaired. +/// +public interface ILaunchContentIntegrityProgressTarget +{ + /// + /// Gets the active version represented by this progress target. + /// + ModificationVersion? ActiveIntegrityVersion { get; } + + /// + /// Gets a value indicating whether progress can currently be reported to this target. + /// + bool CanReportIntegrityProgress { get; } + + /// + /// Applies the initial integrity repair progress state. + /// + /// The initial progress message. + void BeginIntegrityProgress(string message); + + /// + /// Reports an integrity repair progress update. + /// + /// The progress message. + /// The progress percentage. + void ReportIntegrityProgress(string message, int percentage); + + /// + /// Restores the normal UI state after integrity repair progress has completed or failed. + /// + void CompleteIntegrityProgress(); +} diff --git a/GenLauncherGO.UI/Features/Integrity/IntegrityReviewDialog.xaml b/GenLauncherGO.UI/Features/Integrity/IntegrityReviewDialog.xaml new file mode 100644 index 00000000..2b144fd0 --- /dev/null +++ b/GenLauncherGO.UI/Features/Integrity/IntegrityReviewDialog.xaml @@ -0,0 +1,185 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GenLauncherGO.UI/Features/Launcher/Views/MainWindow.xaml.cs b/GenLauncherGO.UI/Features/Launcher/Views/MainWindow.xaml.cs new file mode 100644 index 00000000..4342b6da --- /dev/null +++ b/GenLauncherGO.UI/Features/Launcher/Views/MainWindow.xaml.cs @@ -0,0 +1,449 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Launcher.Contracts; +using GenLauncherGO.UI.Features.Launcher.Models; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Launcher.Support; +using GenLauncherGO.UI.Features.Launcher.ViewModels; +using GenLauncherGO.UI.Features.Mods; +using GenLauncherGO.UI.Shared.Controls; + +namespace GenLauncherGO.UI.Features.Launcher.Views; + +/// +/// Displays the main launcher UI for managing modifications and starting the selected game client. +/// +internal partial class MainWindow : Window, ILauncherWindowWorkflowView +{ + /// + /// The bindable state and model-facing operations for this window. + /// + private readonly MainWindowViewModel _viewModel; + + /// + /// Coordinates modification drag/drop preview behavior. + /// + private readonly LauncherDragDropController _dragDropController; + + /// + /// Coordinates WPF list selection ordering. + /// + private readonly LauncherSelectionController _selectionController; + + /// + /// Coordinates WPF list control state for this window. + /// + private readonly LauncherWindowListController _listController; + + /// + /// Coordinates main-window workflows that are independent of WPF control plumbing. + /// + private readonly LauncherWindowWorkflowCoordinator _windowWorkflowCoordinator; + + /// + /// Initializes a new instance of the class. + /// + /// The bindable state and model-facing operations for this window. + /// The controller that coordinates modification drag/drop preview behavior. + /// The factory that creates WPF list selection controllers. + /// The factory that creates WPF list state controllers. + /// The coordinator for non-control main-window workflows. + public MainWindow( + MainWindowViewModel viewModel, + LauncherDragDropController dragDropController, + LauncherSelectionControllerFactory selectionControllerFactory, + LauncherWindowListControllerFactory listControllerFactory, + LauncherWindowWorkflowCoordinator windowWorkflowCoordinator) + { + _viewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel)); + _dragDropController = dragDropController ?? throw new ArgumentNullException(nameof(dragDropController)); + ArgumentNullException.ThrowIfNull(selectionControllerFactory); + ArgumentNullException.ThrowIfNull(listControllerFactory); + _windowWorkflowCoordinator = windowWorkflowCoordinator ?? + throw new ArgumentNullException(nameof(windowWorkflowCoordinator)); + + InitializeComponent(); + DataContext = _viewModel; + _listController = listControllerFactory.Create( + this, + _viewModel, + ModsList, + PatchesList, + AddonsList); + + _selectionController = selectionControllerFactory.Create( + ModsList, + PatchesList, + AddonsList, + _listController.DisableUi, + _listController.EnableUi, + _listController.RefreshTabs, + _listController.UpdateVisuals, + _listController.RefreshAddonsList, + _listController.RefreshAddonAndPatchTabLabels, + _listController.RestoreFocuses, + _listController.UpdateAddonsAndPatchesAsync); + + this.MouseDown += Window_MouseDown; + this.Closing += MainWindow_Closing; + _viewModel.CloseRequested += ViewModel_CloseRequested; + _viewModel.LaunchGameRequested += ViewModel_LaunchGameRequestedAsync; + _viewModel.LaunchWorldBuilderRequested += ViewModel_LaunchWorldBuilderRequestedAsync; + _viewModel.OpenOptionsRequested += ViewModel_OpenOptionsRequested; + _viewModel.AddRepositoryModificationRequested += ViewModel_AddRepositoryModificationRequestedAsync; + _viewModel.ManualImportRequested += ViewModel_ManualImportRequestedAsync; + _viewModel.ModificationActionRequested += ViewModel_ModificationActionRequestedAsync; + _viewModel.DeleteVersionRequested += ViewModel_DeleteVersionRequested; + _viewModel.ForceQuitRunningProcessRequested += ViewModel_ForceQuitRunningProcessRequested; + + _viewModel.Initialize(); + + ModsList.PreviewMouseMove += ListBox_PreviewMouseMove; + + _selectionController.RunWithChildSelectionSuppressed(_listController.Initialize); + } + + /// + Window ILauncherWindowWorkflowView.OwnerWindow => this; + + /// + IReadOnlyList ILauncherWindowWorkflowView.SelectedModifications => + _listController.SelectedModifications; + + /// + IReadOnlyList ILauncherWindowWorkflowView.SelectedPatches => + _listController.SelectedPatches; + + /// + IReadOnlyList ILauncherWindowWorkflowView.SelectedAddons => + _listController.SelectedAddons; + + /// + IReadOnlyList ILauncherWindowWorkflowView.SelectedVersions => + _listController.SelectedVersions; + + /// + IReadOnlyList ILauncherWindowWorkflowView.SelectedIntegrityProgressTargets => + _listController.SelectedIntegrityProgressTargets; + + /// + Task ILauncherWindowWorkflowView.AddRepositoryModificationToListAsync(string modificationName) + { + return _listController.AddRepositoryModificationToListAsync(modificationName); + } + + /// + void ILauncherWindowWorkflowView.AddImportedContentToList(LauncherManualImportResult importResult) + { + _listController.AddImportedContentToList(importResult); + } + + /// + void ILauncherWindowWorkflowView.DisableUi() + { + _listController.DisableUi(); + } + + /// + void ILauncherWindowWorkflowView.EnableUi() + { + _listController.EnableUi(); + } + + /// + void ILauncherWindowWorkflowView.ShowRunningProcessOverlay(string processName) + { + _viewModel.ShowRunningProcessOverlay(processName); + } + + /// + void ILauncherWindowWorkflowView.HideRunningProcessOverlay() + { + _viewModel.HideRunningProcessOverlay(); + } + + /// + void ILauncherWindowWorkflowView.EnsureContentSelected(ModificationViewModel modification) + { + _listController.EnsureContentSelected(modification); + } + + /// + void ILauncherWindowWorkflowView.RemoveContentFromList(ModificationViewModel modification) + { + _listController.RemoveContentFromList(modification); + } + + /// + void ILauncherWindowWorkflowView.RefreshTabs() + { + _selectionController.RunWithChildSelectionSuppressed(_listController.RefreshTabs); + } + + /// + void ILauncherWindowWorkflowView.RefreshAddonAndPatchTabLabels() + { + _listController.RefreshAddonAndPatchTabLabels(); + } + + /// + void ILauncherWindowWorkflowView.RestoreFocuses() + { + _listController.RestoreFocuses(); + } + + #region Drag and Drop methods + + private void ListBox_PreviewMouseMove(object sender, MouseEventArgs e) + { + _dragDropController.HandlePreviewMouseMove(ModsList, e); + } + + private void ModsList_DragOver(object sender, System.Windows.DragEventArgs e) + { + LauncherDragDropController.ScrollDuringDrag(ModsList, e); + } + + private void BlockDragAndDrop(object sender, MouseEventArgs e) + { + _dragDropController.SetDragAndDropDisabled(true); + } + + private void EnableDragAndDrop(object sender, MouseEventArgs e) + { + _dragDropController.SetDragAndDropDisabled(false); + } + + private void ListBoxItem_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + _dragDropController.CaptureDragStart(e); + } + + private void ListBoxItem_Drop(object sender, DragEventArgs e) + { + if (_dragDropController.TryResolveDropMove( + sender, + e, + ModsList, + out ModificationViewModel source, + out int sourceIndex, + out int targetIndex)) + { + _listController.MoveModInList(source, sourceIndex, targetIndex); + } + + _dragDropController.TerminateDragDropWindow(); + } + + private void Window_MouseDown(object sender, MouseButtonEventArgs e) + { + if (Mouse.LeftButton == MouseButtonState.Pressed) + { + this.DragMove(); + } + } + + private void ModsList_GiveFeedback(object sender, GiveFeedbackEventArgs e) + { + _dragDropController.MovePreviewToCursor(); + } + + #endregion + + #region SelectionsChanges + + private async void ModsList_SelectionChangedAsync(object sender, SelectionChangedEventArgs e) + { + await _selectionController.HandleModsListSelectionChangedAsync(e); + } + + private void PatchesList_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + _selectionController.HandlePatchesListSelectionChanged(sender, e); + } + + private void AddonsList_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + _selectionController.HandleAddonsListSelectionChanged(sender, e); + } + + private void VersionsList_SelectionChanged(object sender, SelectionChangedEventArgs e) + { + _selectionController.HandleVersionsListSelectionChanged(sender); + } + + #endregion + + #region MainWindowEvents + + private void MainWindow_Closing(object? sender, System.ComponentModel.CancelEventArgs e) + { + if (!_windowWorkflowCoordinator.ConfirmCloseDuringActiveLaunch(this)) + { + e.Cancel = true; + return; + } + + if (!_windowWorkflowCoordinator.ConfirmCloseDuringActivePackageActivity(this)) + { + e.Cancel = true; + return; + } + + _viewModel.BruteCancelAllDownloads(); + _viewModel.SaveLauncherDataIfContentLoaded(); + _viewModel.CloseRequested -= ViewModel_CloseRequested; + _viewModel.LaunchGameRequested -= ViewModel_LaunchGameRequestedAsync; + _viewModel.LaunchWorldBuilderRequested -= ViewModel_LaunchWorldBuilderRequestedAsync; + _viewModel.OpenOptionsRequested -= ViewModel_OpenOptionsRequested; + _viewModel.AddRepositoryModificationRequested -= ViewModel_AddRepositoryModificationRequestedAsync; + _viewModel.ManualImportRequested -= ViewModel_ManualImportRequestedAsync; + _viewModel.ModificationActionRequested -= ViewModel_ModificationActionRequestedAsync; + _viewModel.DeleteVersionRequested -= ViewModel_DeleteVersionRequested; + _viewModel.ForceQuitRunningProcessRequested -= ViewModel_ForceQuitRunningProcessRequested; + _viewModel.Dispose(); + } + + #endregion + + #region ExeLaunchHandlers + + private async void ViewModel_LaunchWorldBuilderRequestedAsync(object? sender, EventArgs e) + { + await _windowWorkflowCoordinator.LaunchWorldBuilderAsync(_viewModel, this, CancellationToken.None); + } + + private async void ViewModel_LaunchGameRequestedAsync(object? sender, EventArgs e) + { + await _windowWorkflowCoordinator.LaunchGameAsync(_viewModel, this, CancellationToken.None); + } + + #endregion + + #region MainButtonsEvents + + private void UpdateButton_MouseEnter(object sender, MouseEventArgs e) + { + _dragDropController.SetDragAndDropDisabled(true); + var updateButton = sender as UpdateButton; + if (updateButton != null) + { + updateButton.IsBlinking = false; + } + } + + private void ViewModel_OpenOptionsRequested(object? sender, EventArgs e) + { + _windowWorkflowCoordinator.OpenOptions(_viewModel, this); + } + + private async void ViewModel_AddRepositoryModificationRequestedAsync(object? sender, EventArgs e) + { + await _windowWorkflowCoordinator.AddRepositoryModificationAsync(_viewModel, this); + } + + #endregion + + /// + /// Closes the window after the view model requests closure. + /// + /// The requesting view model. + /// The event arguments. + private void ViewModel_CloseRequested(object? sender, EventArgs e) + { + Close(); + } + + #region ModGridUIEvents + + private async void ViewModel_ModificationActionRequestedAsync( + object? sender, + MainWindowModificationActionRequestedEventArgs e) + { + await _windowWorkflowCoordinator.ApplyModificationActionAsync(this, e, CancellationToken.None); + } + + private void ViewModel_DeleteVersionRequested(object? sender, MainWindowVersionActionRequestedEventArgs e) + { + _windowWorkflowCoordinator.DeleteVersion(this, e.VersionSelection); + } + + /// + /// Handles a request to force close the active launched process. + /// + /// The requesting view model. + /// The event arguments. + private void ViewModel_ForceQuitRunningProcessRequested(object? sender, EventArgs e) + { + _windowWorkflowCoordinator.ForceCloseRunningProcess(this); + } + + #endregion + + #region ManualAddingModifications + + private async void ViewModel_ManualImportRequestedAsync( + object? sender, + MainWindowManualImportRequestedEventArgs e) + { + await _windowWorkflowCoordinator.ImportManualContentAsync( + _viewModel, + this, + e.Kind, + CancellationToken.None); + } + + private void AddImportedContentToList(LauncherManualImportResult importResult) + { + _viewModel.AddImportedContentToList(importResult); + } + + #endregion + + #region ContextMenu Handlers + + private void ContextMenu_Opened(object sender, RoutedEventArgs e) + { + _dragDropController.SetDragAndDropDisabled(true); + if (sender is not ContextMenu contextMenu || + contextMenu.DataContext is not ModificationViewModel modificationViewModel) + { + return; + } + + contextMenu.Closed += ContextMenu_Closed; + + foreach (string hiddenHeader in _windowWorkflowCoordinator.GetHiddenContextMenuHeaders(modificationViewModel)) + { + RemoveMenuItemByName(contextMenu, hiddenHeader); + } + } + + private void ContextMenu_Closed(object sender, RoutedEventArgs e) + { + _dragDropController.SetDragAndDropDisabled(false); + } + + private void RemoveMenuItemByName(ContextMenu menu, string name) + { + foreach (object? item in menu.Items) + { + var menuItem = item as MenuItem; + if (menuItem != null && + String.Equals(menuItem.Header.ToString(), name, StringComparison.OrdinalIgnoreCase)) + { + menu.Items.Remove(item); + return; + } + } + } + + #endregion +} diff --git a/GenLauncherGO.UI/Features/Mods/Composition/ModsUiServiceCollectionExtensions.cs b/GenLauncherGO.UI/Features/Mods/Composition/ModsUiServiceCollectionExtensions.cs new file mode 100644 index 00000000..fb6e438f --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/Composition/ModsUiServiceCollectionExtensions.cs @@ -0,0 +1,28 @@ +using System; +using Microsoft.Extensions.DependencyInjection; + +namespace GenLauncherGO.UI.Features.Mods.Composition; + +/// +/// Provides dependency-injection registrations for the launcher modifications UI feature. +/// +public static class ModsUiServiceCollectionExtensions +{ + /// + /// Registers services used to render launcher modification cards. + /// + /// The service collection used by the application composition root. + /// The same service collection so additional registrations can be chained. + /// + /// Thrown when is . + /// + public static IServiceCollection AddGenLauncherGoModsUi(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.AddLogging(); + services.AddSingleton(); + services.AddSingleton(); + return services; + } +} diff --git a/GenLauncherGO.UI/Features/Mods/Contracts/ILauncherModsContext.cs b/GenLauncherGO.UI/Features/Mods/Contracts/ILauncherModsContext.cs new file mode 100644 index 00000000..4a960d89 --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/Contracts/ILauncherModsContext.cs @@ -0,0 +1,20 @@ +using GenLauncherGO.Core.Startup; +using GenLauncherGO.UI.Shared.Themes; + +namespace GenLauncherGO.UI.Features.Mods.Contracts; + +/// +/// Provides runtime UI context needed by modification tile view models. +/// +public interface ILauncherModsContext +{ + /// + /// Gets the currently managed game. + /// + SupportedGame CurrentlyManagedGame { get; } + + /// + /// Gets the active launcher colors. + /// + ColorsInfo Colors { get; } +} diff --git a/GenLauncherGO.UI/Features/Mods/ModificationImageSourceFactory.cs b/GenLauncherGO.UI/Features/Mods/ModificationImageSourceFactory.cs new file mode 100644 index 00000000..fbc68cc1 --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ModificationImageSourceFactory.cs @@ -0,0 +1,222 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using GenLauncherGO.Core.Startup; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.UI.Features.Mods; + +/// +/// Creates WPF image sources for modification tiles without extracting generated image variants to disk. +/// +public sealed class ModificationImageSourceFactory +{ + /// + /// Stores the manifest resource prefix for default modification images embedded in the UI assembly. + /// + private const string DefaultImageResourceNamePrefix = "GenLauncherGO.UI.Features.Mods.Resources."; + + /// + /// Stores decoded color images by source identity. + /// + private readonly ConcurrentDictionary + _colorImageCache = new(StringComparer.OrdinalIgnoreCase); + + /// + /// Stores decoded grayscale images by source identity. + /// + private readonly ConcurrentDictionary _grayscaleImageCache = + new(StringComparer.OrdinalIgnoreCase); + + /// + /// Receives diagnostics for image load and conversion failures. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The logger that receives image load diagnostics. + public ModificationImageSourceFactory(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Loads the default unknown modification image for the currently managed game. + /// + /// The game variant that determines the default image asset. + /// A value indicating whether the returned image should be grayscale. + /// The decoded and frozen default image source. + public BitmapSource LoadDefaultImage(SupportedGame supportedGame, bool grayscale) + { + string resourceName = DefaultImageResourceNamePrefix + (supportedGame == SupportedGame.ZeroHour + ? "UserAddedModBannerZeroHour.jpg" + : "UserAddedModBannerGenerals.jpg"); + + return LoadResourceImage(resourceName, grayscale); + } + + /// + /// Loads a modification image from disk and optionally converts it to grayscale. + /// + /// The image path to load. + /// A value indicating whether the returned image should be grayscale. + /// The decoded and frozen image source, or when is empty or missing. + /// + /// This method reads the file into memory before returning so callers can safely replace or delete the source file + /// after a successful load. + /// + public BitmapSource? LoadFileImage(string? path, bool grayscale) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + { + return null; + } + + FileInfo fileInfo = new(path); + string cacheKey = CreateFileCacheKey(fileInfo); + + try + { + BitmapSource colorImage = GetOrLoadImage(cacheKey, () => DecodeFileImage(fileInfo.FullName)); + return grayscale ? GetOrCreateGrayscaleImage(cacheKey, colorImage) : colorImage; + } + catch (Exception exception) when (exception is IOException or NotSupportedException + or UnauthorizedAccessException) + { + RemoveCachedImages(cacheKey); + _logger.LogWarning(exception, "Failed to load modification image {ImageFileName}.", fileInfo.Name); + throw; + } + } + + /// + /// Creates a cache key that changes when a file is replaced or edited. + /// + /// The file metadata used for the cache key. + /// A stable cache key for the current file contents. + private static string CreateFileCacheKey(FileInfo fileInfo) + { + fileInfo.Refresh(); + return string.Concat( + "file:", + fileInfo.FullName, + ":", + fileInfo.Length.ToString(System.Globalization.CultureInfo.InvariantCulture), + ":", + fileInfo.LastWriteTimeUtc.Ticks.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + /// + /// Decodes an image file into a frozen bitmap source. + /// + /// The image path to decode. + /// The decoded and frozen bitmap source. + private static BitmapSource DecodeFileImage(string path) + { + using FileStream stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + BitmapImage bitmap = new(); + bitmap.BeginInit(); + bitmap.CacheOption = BitmapCacheOption.OnLoad; + bitmap.StreamSource = stream; + bitmap.EndInit(); + bitmap.Freeze(); + return bitmap; + } + + /// + /// Loads an embedded resource image and optionally converts it to grayscale. + /// + /// The manifest resource name to load. + /// A value indicating whether the returned image should be grayscale. + /// The decoded and frozen image source. + private BitmapSource LoadResourceImage(string resourceName, bool grayscale) + { + string cacheKey = "resource:" + resourceName; + + try + { + BitmapSource colorImage = GetOrLoadImage(cacheKey, () => DecodeResourceImage(resourceName)); + return grayscale ? GetOrCreateGrayscaleImage(cacheKey, colorImage) : colorImage; + } + catch (Exception exception) when (exception is IOException or NotSupportedException) + { + RemoveCachedImages(cacheKey); + _logger.LogError(exception, "Failed to load modification image resource {ImageResourceName}.", + resourceName); + throw; + } + } + + /// + /// Decodes an embedded resource image into a frozen bitmap source. + /// + /// The manifest resource name to decode. + /// The decoded and frozen bitmap source. + private static BitmapSource DecodeResourceImage(string resourceName) + { + Stream stream = typeof(ModificationImageSourceFactory).Assembly.GetManifestResourceStream(resourceName) + ?? throw new IOException("The modification image resource was not found."); + + using (stream) + { + return DecodeStreamImage(stream); + } + } + + /// + /// Decodes an image stream into a frozen bitmap source. + /// + /// The stream to decode. + /// The decoded and frozen bitmap source. + private static BitmapSource DecodeStreamImage(Stream stream) + { + BitmapImage bitmap = new(); + bitmap.BeginInit(); + bitmap.CacheOption = BitmapCacheOption.OnLoad; + bitmap.StreamSource = stream; + bitmap.EndInit(); + bitmap.Freeze(); + return bitmap; + } + + /// + /// Gets an image from the color cache or loads it on demand. + /// + /// The cache key for the image. + /// The factory that loads the image when it is not cached. + /// The cached or loaded image. + private BitmapSource GetOrLoadImage(string cacheKey, Func imageFactory) + { + return _colorImageCache.GetOrAdd(cacheKey, _ => imageFactory()); + } + + /// + /// Gets a grayscale image from the cache or converts it on demand. + /// + /// The cache key for the source image. + /// The color image to convert when no grayscale image is cached. + /// The cached or converted grayscale image. + private BitmapSource GetOrCreateGrayscaleImage(string cacheKey, BitmapSource source) + { + return _grayscaleImageCache.GetOrAdd(cacheKey, _ => + { + FormatConvertedBitmap grayscaleImage = new(source, PixelFormats.Gray8, null, 0); + grayscaleImage.Freeze(); + return grayscaleImage; + }); + } + + /// + /// Removes cached entries for a source after a load failure. + /// + /// The cache key to remove. + private void RemoveCachedImages(string cacheKey) + { + _colorImageCache.TryRemove(cacheKey, out _); + _grayscaleImageCache.TryRemove(cacheKey, out _); + } +} diff --git a/GenLauncherGO.UI/Features/Mods/ModificationVersionSelection.cs b/GenLauncherGO.UI/Features/Mods/ModificationVersionSelection.cs new file mode 100644 index 00000000..d5d584fc --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ModificationVersionSelection.cs @@ -0,0 +1,47 @@ +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.UI.Features.Mods; + +/// +/// Represents one installed modification version in a tile version selector. +/// +public sealed class ModificationVersionSelection +{ + /// + /// Initializes a new instance of the class. + /// + /// The selected modification version. + /// The version display text. + /// The owning modification view model. + public ModificationVersionSelection( + ModificationVersion selectedModification, + string version, + ModificationViewModel modificationViewModel) + { + SelectedVersion = selectedModification; + VersionName = version; + ModificationViewModel = modificationViewModel; + } + + /// + /// Initializes a new instance of the class. + /// + public ModificationVersionSelection() + { + } + + /// + /// Gets or sets the version display text. + /// + public string VersionName { get; set; } = string.Empty; + + /// + /// Gets or sets the selected modification version. + /// + public ModificationVersion SelectedVersion { get; set; } = null!; + + /// + /// Gets or sets the owning modification view model. + /// + public ModificationViewModel ModificationViewModel { get; set; } = null!; +} diff --git a/GenLauncherGO.UI/Features/Mods/ModificationViewModel.cs b/GenLauncherGO.UI/Features/Mods/ModificationViewModel.cs new file mode 100644 index 00000000..d5f37d1e --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ModificationViewModel.cs @@ -0,0 +1,1228 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Media; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Mods.Contracts; +using GenLauncherGO.UI.Features.Mods.ViewModels; +using GenLauncherGO.UI.Shared.Localization; +using GenLauncherGO.UI.Shared.Themes; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.UI.Features.Mods; + +/// +/// Represents bindable UI state for a launcher modification tile. +/// +public sealed class ModificationViewModel : ILaunchContentIntegrityProgressTarget, INotifyPropertyChanged +{ + /// + /// The fallback active accent brush used when tests or startup state have not provided theme colors. + /// + private static readonly Brush _fallbackActiveBrush = new SolidColorBrush(Color.FromRgb(186, 255, 12)); + + /// + /// The fallback primary border brush used when tests or startup state have not provided theme colors. + /// + private static readonly Brush _fallbackBorderBrush = new SolidColorBrush(Color.FromRgb(0, 227, 255)); + + /// + /// The fallback default text brush used when tests or startup state have not provided theme colors. + /// + private static readonly Brush _fallbackDefaultTextBrush = Brushes.White; + + /// + /// The fallback download status text brush used when tests or startup state have not provided theme colors. + /// + private static readonly Brush _fallbackDownloadTextBrush = Brushes.Black; + + /// + /// The fallback inactive brush used when tests or startup state have not provided theme colors. + /// + private static readonly Brush _fallbackInactiveBrush = Brushes.DarkGray; + + /// + /// The fallback progress background brush used when tests or startup state have not provided theme colors. + /// + private static readonly Brush _fallbackProgressBackgroundBrush = Brushes.Black; + + /// + /// The fallback active progress brush used when tests or startup state have not provided theme colors. + /// + private static readonly Brush _fallbackActiveProgressBrush = new SolidColorBrush(Color.FromRgb(37, 52, 255)); + + /// + /// The non-visual tile state. + /// + private readonly ModificationTileState _state; + + /// + /// The current launcher UI context. + /// + private readonly ILauncherModsContext _launcherContext; + + /// + /// The provider used to load modification tile images. + /// + private readonly ModificationTileImageProvider _imageProvider; + + /// + /// The localized string provider. + /// + private readonly ILauncherStringLocalizer _stringLocalizer; + + /// + /// The current tile image source. + /// + private ImageSource? _imageSource; + + /// + /// The current modification name foreground brush. + /// + private Brush _nameForeground = _fallbackInactiveBrush; + + /// + /// The current latest-version foreground brush. + /// + private Brush _versionForeground = _fallbackInactiveBrush; + + /// + /// The current modification name font weight. + /// + private FontWeight _nameFontWeight = FontWeights.Normal; + + /// + /// The current version selector visibility. + /// + private Visibility _versionSelectorVisibility = Visibility.Hidden; + + /// + /// The current drag-and-drop preview visibility. + /// + private Visibility _dragAndDropVisibility = Visibility.Hidden; + + /// + /// The current update highlight visibility. + /// + private Visibility _updateRectangleVisibility = Visibility.Hidden; + + /// + /// The current update button visibility. + /// + private Visibility _updateButtonVisibility = Visibility.Visible; + + /// + /// The current support button visibility. + /// + private Visibility _supportButtonVisibility = Visibility.Visible; + + /// + /// The current network information button visibility. + /// + private Visibility _networkInfoVisibility = Visibility.Visible; + + /// + /// The current changelog button visibility. + /// + private Visibility _changeLogVisibility = Visibility.Visible; + + /// + /// The current image border thickness. + /// + private Thickness _imageBorderThickness = new(0); + + /// + /// The current image border brush. + /// + private Brush _imageBorderBrush = _fallbackInactiveBrush; + + /// + /// The current progress bar background brush. + /// + private Brush _progressBackground = _fallbackProgressBackgroundBrush; + + /// + /// The current progress bar fill brush. + /// + private Brush _progressForeground = _fallbackActiveProgressBrush; + + /// + /// The current progress bar border brush. + /// + private Brush _progressBorderBrush = _fallbackInactiveBrush; + + /// + /// The current progress text foreground brush. + /// + private Brush _progressTextForeground = _fallbackDefaultTextBrush; + + /// + /// The current progress value. + /// + private double _progressValue; + + /// + /// The current progress message. + /// + private string _progressMessage = string.Empty; + + /// + /// The current update button content. + /// + private string _updateButtonContent; + + /// + /// The current support button content. + /// + private string _supportButtonContent; + + /// + /// The current changelog button content. + /// + private string _changeLogButtonContent; + + /// + /// The current network information button content. + /// + private string _networkInfoButtonContent; + + /// + /// A value indicating whether the update button is enabled. + /// + private bool _updateButtonEnabled = true; + + /// + /// A value indicating whether the update button should blink. + /// + private bool _updateButtonBlinking; + + /// + /// A value indicating whether the support button should blink. + /// + private bool _supportButtonBlinking; + + /// + /// A value indicating whether integrity repair progress is active for this tile. + /// + private bool _integrityProgressActive; + + /// + /// A value indicating whether child-content package activity is being mirrored onto this tile. + /// + private bool _forwardedChildPackageActivityActive; + + /// + /// A value indicating whether the version selector is enabled. + /// + private bool _isVersionSelectorEnabled = true; + + /// + /// The selected version selector entry. + /// + private ModificationVersionSelection? _selectedVersionOption; + + /// + /// Initializes a new instance of the class. + /// + /// The modification represented by the tile. + /// The factory used to load modification tile images. + /// The current launcher UI context. + /// The service used to inspect modification image files. + /// The localized string provider. + /// The logger used to record tile image failures. + public ModificationViewModel( + GameModification modification, + ModificationImageSourceFactory imageSourceFactory, + ILauncherModsContext launcherContext, + IModificationImageFileService modificationImageFileService, + ILauncherStringLocalizer stringLocalizer, + ILogger logger) + { + _launcherContext = launcherContext ?? throw new ArgumentNullException(nameof(launcherContext)); + _stringLocalizer = stringLocalizer ?? throw new ArgumentNullException(nameof(stringLocalizer)); + _imageProvider = new ModificationTileImageProvider( + imageSourceFactory, + launcherContext, + modificationImageFileService, + logger); + _state = new ModificationTileState(modification, stringLocalizer); + + _updateButtonContent = _stringLocalizer["Update"]; + _supportButtonContent = _stringLocalizer["Donate"]; + _changeLogButtonContent = _stringLocalizer["ChangelogOnly"]; + _networkInfoButtonContent = _stringLocalizer["PlayOnline"]; + + InitializeVisualState(); + } + + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// + /// Occurs when package download or repair activity state changes for this tile. + /// + public event EventHandler? PackageActivityChanged; + + /// + /// Gets the modification represented by the tile. + /// + public GameModification ContainerModification => _state.Modification; + + /// + /// Gets the latest available modification version. + /// + public ModificationVersion LatestVersion => _state.LatestVersion; + + /// + /// Gets the currently selected modification version. + /// + public ModificationVersion? SelectedVersion => _state.SelectedVersion; + + /// + /// Gets or sets the active package download operation. + /// + public IPackageDownloadOperation? Downloader { get; set; } + + /// + /// Gets the modification display name. + /// + public string NameInfo => _state.NameInfo; + + /// + /// Gets the latest-version display text. + /// + public string LatestVersionInfo => _state.LatestVersionInfo; + + /// + /// Gets a value indicating whether the modification can be launched. + /// + public bool ReadyToRun => _state.ReadyToRun; + + /// + /// Gets or sets the tile colors. + /// + public ColorsInfo Colors { get; set; } = null!; + + /// + /// Gets a value indicating whether the modification is local-only. + /// + public bool LocalMod => _state.LocalMod; + + /// + public ModificationVersion? ActiveIntegrityVersion => SelectedVersion ?? LatestVersion; + + /// + public bool CanReportIntegrityProgress => ActiveIntegrityVersion != null; + + /// + /// Gets a value indicating whether package download, repair, or forwarded child activity is active. + /// + public bool HasActivePackageActivity => + Downloader != null || _integrityProgressActive || _forwardedChildPackageActivityActive; + + /// + /// Gets installed version options shown by the version selector. + /// + public ObservableCollection VersionOptions { get; } = new(); + + /// + /// Gets or sets the selected installed version option. + /// + public ModificationVersionSelection? SelectedVersionOption + { + get => _selectedVersionOption; + set => SetProperty(ref _selectedVersionOption, value); + } + + /// + /// Gets the tile image source. + /// + public ImageSource? ImageSource + { + get => _imageSource; + private set => SetProperty(ref _imageSource, value); + } + + /// + /// Gets the modification name foreground brush. + /// + public Brush NameForeground + { + get => _nameForeground; + private set => SetProperty(ref _nameForeground, value); + } + + /// + /// Gets the latest-version foreground brush. + /// + public Brush VersionForeground + { + get => _versionForeground; + private set => SetProperty(ref _versionForeground, value); + } + + /// + /// Gets the modification name font weight. + /// + public FontWeight NameFontWeight + { + get => _nameFontWeight; + private set => SetProperty(ref _nameFontWeight, value); + } + + /// + /// Gets the version selector visibility. + /// + public Visibility VersionSelectorVisibility + { + get => _versionSelectorVisibility; + private set => SetProperty(ref _versionSelectorVisibility, value); + } + + /// + /// Gets the drag-and-drop preview visibility. + /// + public Visibility DragAndDropVisibility + { + get => _dragAndDropVisibility; + private set => SetProperty(ref _dragAndDropVisibility, value); + } + + /// + /// Gets the update highlight visibility. + /// + public Visibility UpdateRectangleVisibility + { + get => _updateRectangleVisibility; + private set => SetProperty(ref _updateRectangleVisibility, value); + } + + /// + /// Gets the update button visibility. + /// + public Visibility UpdateButtonVisibility + { + get => _updateButtonVisibility; + private set => SetProperty(ref _updateButtonVisibility, value); + } + + /// + /// Gets the support button visibility. + /// + public Visibility SupportButtonVisibility + { + get => _supportButtonVisibility; + private set => SetProperty(ref _supportButtonVisibility, value); + } + + /// + /// Gets the network information button visibility. + /// + public Visibility NetworkInfoVisibility + { + get => _networkInfoVisibility; + private set => SetProperty(ref _networkInfoVisibility, value); + } + + /// + /// Gets the changelog button visibility. + /// + public Visibility ChangeLogVisibility + { + get => _changeLogVisibility; + private set => SetProperty(ref _changeLogVisibility, value); + } + + /// + /// Gets the image border thickness. + /// + public Thickness ImageBorderThickness + { + get => _imageBorderThickness; + private set => SetProperty(ref _imageBorderThickness, value); + } + + /// + /// Gets the image border brush. + /// + public Brush ImageBorderBrush + { + get => _imageBorderBrush; + private set => SetProperty(ref _imageBorderBrush, value); + } + + /// + /// Gets the progress bar background brush. + /// + public Brush ProgressBackground + { + get => _progressBackground; + private set => SetProperty(ref _progressBackground, value); + } + + /// + /// Gets the progress bar fill brush. + /// + public Brush ProgressForeground + { + get => _progressForeground; + private set => SetProperty(ref _progressForeground, value); + } + + /// + /// Gets the progress bar border brush. + /// + public Brush ProgressBorderBrush + { + get => _progressBorderBrush; + private set => SetProperty(ref _progressBorderBrush, value); + } + + /// + /// Gets the progress text foreground brush. + /// + public Brush ProgressTextForeground + { + get => _progressTextForeground; + private set => SetProperty(ref _progressTextForeground, value); + } + + /// + /// Gets the progress value. + /// + public double ProgressValue + { + get => _progressValue; + private set => SetProperty(ref _progressValue, value); + } + + /// + /// Gets the progress message. + /// + public string ProgressMessage + { + get => _progressMessage; + private set => SetProperty(ref _progressMessage, value); + } + + /// + /// Gets the update button content. + /// + public string UpdateButtonContent + { + get => _updateButtonContent; + private set => SetProperty(ref _updateButtonContent, value); + } + + /// + /// Gets the support button content. + /// + public string SupportButtonContent + { + get => _supportButtonContent; + private set => SetProperty(ref _supportButtonContent, value); + } + + /// + /// Gets the changelog button content. + /// + public string ChangeLogButtonContent + { + get => _changeLogButtonContent; + private set => SetProperty(ref _changeLogButtonContent, value); + } + + /// + /// Gets the network information button content. + /// + public string NetworkInfoButtonContent + { + get => _networkInfoButtonContent; + private set => SetProperty(ref _networkInfoButtonContent, value); + } + + /// + /// Gets a value indicating whether the update button is enabled. + /// + public bool UpdateButtonEnabled + { + get => _updateButtonEnabled; + private set => SetProperty(ref _updateButtonEnabled, value); + } + + /// + /// Gets a value indicating whether the update button should blink. + /// + public bool UpdateButtonBlinking + { + get => _updateButtonBlinking; + private set => SetProperty(ref _updateButtonBlinking, value); + } + + /// + /// Gets a value indicating whether the support button should blink. + /// + public bool SupportButtonBlinking + { + get => _supportButtonBlinking; + private set => SetProperty(ref _supportButtonBlinking, value); + } + + /// + /// Gets a value indicating whether the version selector is enabled. + /// + public bool IsVersionSelectorEnabled + { + get => _isVersionSelectorEnabled; + private set => SetProperty(ref _isVersionSelectorEnabled, value); + } + + /// + /// Refreshes the modification and version state from the backing model. + /// + public void UpdataContainerData() + { + _state.RefreshFromModification(); + OnStatePropertiesChanged(); + } + + /// + /// Applies the drag-and-drop visual state to the tile. + /// + public void SetDragAndDropMod() + { + DragAndDropVisibility = Visibility.Visible; + SetSelectedStatus(); + } + + /// + /// Clears the drag-and-drop visual state from the tile. + /// + public void RemoveDragAndDropMod() + { + DragAndDropVisibility = Visibility.Hidden; + SetUnSelectedStatus(); + } + + /// + /// Cancels the active package download and restores launch readiness when possible. + /// + public void CancelDownload() + { + if (Downloader != null) + { + SetUpdateButtonEnabled(false); + Downloader.CancelDownload(); + + UpdataContainerData(); + + if (ContainerModification.ModificationVersions.Any(modification => modification.Installed)) + { + _state.MarkReadyToRun(); + OnStatePropertiesChanged(); + } + } + + SetUnactiveProgressBar(); + } + + /// + /// Disposes the active package download operation without updating tile state. + /// + public void BruteCancelDownload() + { + Downloader?.Dispose(); + } + + /// + /// Refreshes tile state from current modification and download state. + /// + public void Refresh() + { + if (!HasActivePackageActivity) + { + SetUnactiveProgressBar(); + } + else + { + SetActiveProgressBar(); + } + + if (ContainerModification.IsSelected) + { + SetSelectedStatus(); + } + else + { + SetUnSelectedStatus(); + } + } + + /// + /// Applies the selected visual state to the tile. + /// + public void SetSelectedStatus() + { + NameForeground = ActiveBrush; + VersionForeground = DefaultTextBrush; + NameFontWeight = FontWeights.Bold; + NetworkInfoVisibility = String.IsNullOrEmpty(ContainerModification.NetworkInfo) + ? Visibility.Hidden + : Visibility.Visible; + ChangeLogVisibility = String.IsNullOrEmpty(ContainerModification.NewsLink) + ? Visibility.Hidden + : Visibility.Visible; + SupportButtonVisibility = String.IsNullOrEmpty(ContainerModification.SupportLink) + ? Visibility.Hidden + : Visibility.Visible; + + SetColorfulImage(); + + VersionSelectorVisibility = ContainerModification.ModificationType == ModificationType.Advertising + ? Visibility.Hidden + : Visibility.Visible; + + SetImageBorderBrush(ActiveBrush); + } + + /// + /// Applies the unselected visual state to the tile. + /// + public void SetUnSelectedStatus() + { + NameForeground = InactiveBrush; + VersionForeground = InactiveBrush; + NameFontWeight = FontWeights.Normal; + VersionSelectorVisibility = Visibility.Hidden; + + SetBlackWhiteImage(); + + if (ContainerModification.ModificationType != ModificationType.Advertising) + { + NetworkInfoVisibility = Visibility.Hidden; + ChangeLogVisibility = Visibility.Hidden; + SupportButtonVisibility = Visibility.Hidden; + } + + SetImageBorderBrush(InactiveBrush); + } + + /// + /// Applies the inactive visual state to the progress bar. + /// + public void SetUnactiveProgressBar() + { + ProgressBackground = ProgressBackgroundBrush; + ProgressForeground = ActiveBrush; + ProgressBorderBrush = InactiveBrush; + ProgressTextForeground = DefaultTextBrush; + } + + /// + /// Applies the active visual state to the progress bar. + /// + public void SetActiveProgressBar() + { + ProgressBackground = ActiveProgressBrush; + ProgressForeground = ActiveBrush; + ProgressBorderBrush = BorderBrush; + ProgressTextForeground = DownloadTextBrush; + if (ContainerModification.ModificationType == ModificationType.Mod) + { + UpdateRectangleVisibility = Visibility.Hidden; + } + } + + /// + /// Updates bindable tile state from current modification and download state. + /// + public void UpdateUIelements() + { + if (Downloader == null || Downloader.GetResult().Crashed) + { + Downloader = null; + ResetDownloadVisuals(); + + UpdataContainerData(); + + if (ContainerModification.ModificationType != ModificationType.Advertising) + { + UpdateComboBox(); + SelectItemInComboBox(); + } + else + { + HideVersionSelector(); + } + } + + HideMissingContentButtons(); + } + + /// + /// Rebuilds the installed-version selector. + /// + public void UpdateComboBox() + { + if (LatestVersion.Installed) + { + UpdateButtonContent = _stringLocalizer["UpToDate"]; + UpdateButtonEnabled = false; + UpdateButtonBlinking = false; + if (ContainerModification.ModificationType == ModificationType.Mod) + { + UpdateRectangleVisibility = Visibility.Hidden; + } + } + else + { + UpdateButtonContent = _stringLocalizer["Update"]; + UpdateButtonEnabled = true; + if (ContainerModification.ModificationType == ModificationType.Mod) + { + UpdateRectangleVisibility = Visibility.Visible; + UpdateButtonBlinking = true; + } + } + + VersionOptions.Clear(); + foreach (ModificationVersion version in ContainerModification.ModificationVersions + .Where(modificationVersion => modificationVersion.Installed) + .OrderBy(modificationVersion => modificationVersion)) + { + VersionOptions.Add(new ModificationVersionSelection( + version, + version.Version, + this)); + } + } + + /// + /// Selects the current installed version in the version selector. + /// + public void SelectItemInComboBox() + { + if (ContainerModification.ModificationVersions.Count == 0) + { + IsVersionSelectorEnabled = false; + SelectedVersionOption = null; + return; + } + + if (ContainerModification.ModificationVersions.Count == 1 && !LatestVersion.Installed) + { + SetUIToInstallMode(); + SelectedVersionOption = null; + return; + } + + IsVersionSelectorEnabled = true; + string versionString; + if (ReadyToRun) + { + versionString = SelectedVersion?.Version ?? string.Empty; + } + else + { + ModificationVersion selectedVersion = _state.SelectLatestInstalledVersion(); + OnStatePropertiesChanged(); + versionString = selectedVersion.Version; + } + + SelectedVersionOption = VersionOptions.FirstOrDefault(selection => + String.Equals(selection.VersionName, versionString, StringComparison.Ordinal)); + } + + /// + /// Assigns the active package download operation. + /// + /// The active package download operation. + public void SetDownloader(IPackageDownloadOperation downloader) + { + Downloader = downloader; + OnPackageActivityChanged(); + } + + /// + /// Clears the active package download operation. + /// + public void ClearDownloader() + { + Downloader = null; + OnPackageActivityChanged(); + } + + /// + /// Prepares tile state for package download state. + /// + public void PrepareControlsToDownloadMode() + { + UpdateButtonContent = _stringLocalizer["Cancel"]; + IsVersionSelectorEnabled = false; + _state.MarkNotReadyToRun(); + OnStatePropertiesChanged(); + + SetActiveProgressBar(); + } + + /// + /// Sets the tile status message. + /// + /// The status message. + public void SetUIMessages(string message) + { + ProgressMessage = message; + } + + /// + /// Enables or disables the tile update button. + /// + /// A value indicating whether the update button should be enabled. + public void SetUpdateButtonEnabled(bool isEnabled) + { + UpdateButtonEnabled = isEnabled; + } + + /// + /// Enables or disables the support button notification highlight. + /// + /// A value indicating whether the support button should blink. + public void SetSupportButtonBlinking(bool isBlinking) + { + SupportButtonBlinking = isBlinking; + } + + /// + /// Sets the tile status message and progress percentage. + /// + /// The status message. + /// The progress percentage. + public void SetUIMessages(string message, int percentage) + { + ProgressMessage = message; + ProgressValue = percentage; + if (HasActivePackageActivity) + { + OnPackageActivityChanged(); + } + } + + /// + /// Applies the install-needed visual state to the tile. + /// + public void SetUIToInstallMode() + { + IsVersionSelectorEnabled = false; + UpdateButtonContent = _stringLocalizer["Install"]; + _state.MarkNotReadyToRun(); + OnStatePropertiesChanged(); + + if (ContainerModification.ModificationType == ModificationType.Mod) + { + UpdateRectangleVisibility = Visibility.Hidden; + } + } + + /// + public void BeginIntegrityProgress(string message) + { + _integrityProgressActive = true; + SetActiveProgressBar(); + SetUIMessages(message, 0); + OnPackageActivityChanged(); + } + + /// + public void ReportIntegrityProgress(string message, int percentage) + { + SetUIMessages(message, percentage); + } + + /// + public void CompleteIntegrityProgress() + { + _integrityProgressActive = false; + UpdateUIelements(); + SetUnactiveProgressBar(); + OnPackageActivityChanged(); + } + + /// + /// Mirrors child-content package activity onto this parent tile. + /// + /// The child package activity message. + /// The child package activity percentage. + public void ReportForwardedChildPackageActivity(string message, int percentage) + { + if (Downloader != null || _integrityProgressActive) + { + return; + } + + _forwardedChildPackageActivityActive = true; + SetActiveProgressBar(); + SetUIMessages(message, percentage); + OnPackageActivityChanged(); + } + + /// + /// Clears mirrored child-content package activity from this parent tile. + /// + public void CompleteForwardedChildPackageActivity() + { + if (!_forwardedChildPackageActivityActive) + { + return; + } + + _forwardedChildPackageActivityActive = false; + UpdateUIelements(); + SetUnactiveProgressBar(); + OnPackageActivityChanged(); + } + + /// + /// Gets the active accent brush. + /// + private Brush ActiveBrush => ResolveBrush(_launcherContext.Colors.GenLauncherActiveColor, _fallbackActiveBrush); + + /// + /// Gets the primary border brush. + /// + private Brush BorderBrush => ResolveBrush(_launcherContext.Colors.GenLauncherBorderColor, _fallbackBorderBrush); + + /// + /// Gets the default text brush. + /// + private Brush DefaultTextBrush => + ResolveBrush(_launcherContext.Colors.GenLauncherDefaultTextColor, _fallbackDefaultTextBrush); + + /// + /// Gets the download text brush. + /// + private Brush DownloadTextBrush => + ResolveBrush(_launcherContext.Colors.GenLauncherDownloadTextColor, _fallbackDownloadTextBrush); + + /// + /// Gets the inactive brush. + /// + private Brush InactiveBrush => + ResolveBrush(_launcherContext.Colors.GenLauncherInactiveBorder, _fallbackInactiveBrush); + + /// + /// Gets the inactive progress background brush. + /// + private Brush ProgressBackgroundBrush => + ResolveBrush(_launcherContext.Colors.GenLauncherDarkBackGround, _fallbackProgressBackgroundBrush); + + /// + /// Gets the active progress background brush. + /// + private Brush ActiveProgressBrush => _launcherContext.Colors.GenLauncherButtonSelectionColor == default + ? _fallbackActiveProgressBrush + : new SolidColorBrush(_launcherContext.Colors.GenLauncherButtonSelectionColor); + + /// + /// Initializes all bindable visual state from the backing modification. + /// + private void InitializeVisualState() + { + ResetDownloadVisuals(); + UpdateUIelements(); + + if (ContainerModification.IsSelected) + { + SetSelectedStatus(); + } + else + { + SetUnSelectedStatus(); + } + } + + /// + /// Resets download controls to their idle state. + /// + private void ResetDownloadVisuals() + { + ProgressValue = 0; + ProgressMessage = string.Empty; + UpdateButtonVisibility = Visibility.Visible; + UpdateButtonContent = _stringLocalizer["Update"]; + SupportButtonContent = _stringLocalizer["Donate"]; + ChangeLogButtonContent = _stringLocalizer["ChangelogOnly"]; + NetworkInfoButtonContent = _stringLocalizer["PlayOnline"]; + ProgressTextForeground = DefaultTextBrush; + + if (ContainerModification.ModificationType != ModificationType.Advertising) + { + return; + } + + UpdateButtonContent = _stringLocalizer["AdvertisingDonationAlerts"]; + if (String.IsNullOrEmpty(ContainerModification.SimpleDownloadLink)) + { + UpdateButtonVisibility = Visibility.Hidden; + } + + ChangeLogButtonContent = _stringLocalizer["AdvertisingBoostyLink"]; + NetworkInfoButtonContent = _stringLocalizer["AdvertisingYouTubeRuLink"]; + } + + /// + /// Hides the version selector for advertising tiles. + /// + private void HideVersionSelector() + { + VersionSelectorVisibility = Visibility.Hidden; + } + + /// + /// Hides buttons whose backing links are unavailable. + /// + private void HideMissingContentButtons() + { + if (String.IsNullOrEmpty(ContainerModification.NewsLink)) + { + ChangeLogVisibility = Visibility.Hidden; + } + + if (String.IsNullOrEmpty(ContainerModification.NetworkInfo)) + { + NetworkInfoVisibility = Visibility.Hidden; + } + + if (String.IsNullOrEmpty(ContainerModification.SupportLink)) + { + SupportButtonVisibility = Visibility.Hidden; + } + } + + /// + /// Applies the grayscale image for the current tile. + /// + private void SetBlackWhiteImage() + { + ApplyImage(_imageProvider.LoadGrayscaleImage( + ContainerModification, + LatestVersion, + LocalMod)); + } + + /// + /// Applies the color image for the current tile. + /// + private void SetColorfulImage() + { + ApplyImage(_imageProvider.LoadColorImage( + ContainerModification, + LatestVersion, + LocalMod)); + } + + /// + /// Applies a loaded image when one is available. + /// + /// The loaded image source. + private void ApplyImage(ImageSource? imageSource) + { + if (imageSource != null) + { + ImageSource = imageSource; + } + } + + /// + /// Updates the image border brush when the tile has an image. + /// + /// The brush to apply when an image exists. + private void SetImageBorderBrush(Brush brush) + { + if (ImageSource == null) + { + ImageBorderThickness = new Thickness(0); + return; + } + + ImageBorderThickness = new Thickness(2); + ImageBorderBrush = brush; + } + + /// + /// Raises property changes for state properties computed by . + /// + private void OnStatePropertiesChanged() + { + OnPropertyChanged(nameof(ContainerModification)); + OnPropertyChanged(nameof(LatestVersion)); + OnPropertyChanged(nameof(SelectedVersion)); + OnPropertyChanged(nameof(NameInfo)); + OnPropertyChanged(nameof(LatestVersionInfo)); + OnPropertyChanged(nameof(ReadyToRun)); + OnPropertyChanged(nameof(LocalMod)); + OnPropertyChanged(nameof(ActiveIntegrityVersion)); + OnPropertyChanged(nameof(CanReportIntegrityProgress)); + } + + /// + /// Raises change notifications for package activity state. + /// + private void OnPackageActivityChanged() + { + OnPropertyChanged(nameof(HasActivePackageActivity)); + PackageActivityChanged?.Invoke(this, EventArgs.Empty); + } + + /// + /// Updates a property field and raises when the value changed. + /// + /// The property value type. + /// The backing field. + /// The new value. + /// The property name supplied by the compiler. + /// when the value changed; otherwise, . + private bool SetProperty( + ref T field, + T value, + [CallerMemberName] string? propertyName = null) + { + if (EqualityComparer.Default.Equals(field, value)) + { + return false; + } + + field = value; + OnPropertyChanged(propertyName); + return true; + } + + /// + /// Raises the event. + /// + /// The changed property name. + private void OnPropertyChanged(string? propertyName) + { + if (!String.IsNullOrWhiteSpace(propertyName)) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } + } + + /// + /// Resolves a theme brush with a fallback. + /// + /// The theme brush. + /// The fallback brush. + /// The resolved brush. + private static Brush ResolveBrush(Brush? brush, Brush fallback) + { + return brush ?? fallback; + } +} diff --git a/GenLauncherGO.UI/Features/Mods/ModificationViewModelFactory.cs b/GenLauncherGO.UI/Features/Mods/ModificationViewModelFactory.cs new file mode 100644 index 00000000..f680ee2b --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ModificationViewModelFactory.cs @@ -0,0 +1,78 @@ +using System; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.UI.Features.Mods.Contracts; +using GenLauncherGO.UI.Shared.Localization; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.UI.Features.Mods; + +/// +/// Creates modification tile view models with shared launcher UI dependencies. +/// +internal sealed class ModificationViewModelFactory +{ + /// + /// The factory used to load modification tile images. + /// + private readonly ModificationImageSourceFactory _imageSourceFactory; + + /// + /// The current launcher UI context. + /// + private readonly ILauncherModsContext _launcherContext; + + /// + /// The service used to inspect modification image files. + /// + private readonly IModificationImageFileService _modificationImageFileService; + + /// + /// The localized string provider. + /// + private readonly ILauncherStringLocalizer _stringLocalizer; + + /// + /// The logger used to record tile image failures. + /// + private readonly ILogger _logger; + + /// + /// Initializes a new instance of the class. + /// + /// The factory used to load modification tile images. + /// The current launcher UI context. + /// The service used to inspect modification image files. + /// The localized string provider. + /// The logger used to record tile image failures. + public ModificationViewModelFactory( + ModificationImageSourceFactory imageSourceFactory, + ILauncherModsContext launcherContext, + IModificationImageFileService modificationImageFileService, + ILauncherStringLocalizer stringLocalizer, + ILogger logger) + { + _imageSourceFactory = imageSourceFactory ?? throw new ArgumentNullException(nameof(imageSourceFactory)); + _launcherContext = launcherContext ?? throw new ArgumentNullException(nameof(launcherContext)); + _modificationImageFileService = modificationImageFileService ?? + throw new ArgumentNullException(nameof(modificationImageFileService)); + _stringLocalizer = stringLocalizer ?? throw new ArgumentNullException(nameof(stringLocalizer)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Creates a tile view model for a catalog modification. + /// + /// The catalog modification. + /// The created tile view model. + public ModificationViewModel Create(GameModification modification) + { + return new ModificationViewModel( + modification, + _imageSourceFactory, + _launcherContext, + _modificationImageFileService, + _stringLocalizer, + _logger); + } +} diff --git a/GenLauncherNet/Images/uamG.jpg b/GenLauncherGO.UI/Features/Mods/Resources/UserAddedModBannerGenerals.jpg similarity index 100% rename from GenLauncherNet/Images/uamG.jpg rename to GenLauncherGO.UI/Features/Mods/Resources/UserAddedModBannerGenerals.jpg diff --git a/GenLauncherNet/Images/uamZH.jpg b/GenLauncherGO.UI/Features/Mods/Resources/UserAddedModBannerZeroHour.jpg similarity index 100% rename from GenLauncherNet/Images/uamZH.jpg rename to GenLauncherGO.UI/Features/Mods/Resources/UserAddedModBannerZeroHour.jpg diff --git a/GenLauncherGO.UI/Features/Mods/ViewModels/AddModificationViewModel.cs b/GenLauncherGO.UI/Features/Mods/ViewModels/AddModificationViewModel.cs new file mode 100644 index 00000000..e61e729f --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ViewModels/AddModificationViewModel.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Windows.Input; +using GenLauncherGO.UI.Shared.Commands; + +namespace GenLauncherGO.UI.Features.Mods.ViewModels; + +/// +/// Provides bindable selection state and actions for choosing a repository modification. +/// +internal sealed class AddModificationViewModel +{ + /// + /// Initializes a new instance of the class. + /// + /// The modification names available for selection. + public AddModificationViewModel(IReadOnlyList modificationNames) + { + ArgumentNullException.ThrowIfNull(modificationNames); + + ModificationNames = modificationNames.ToList(); + AcceptCommand = new RelayCommand(_ => AcceptSelection()); + CancelCommand = new RelayCommand(_ => Cancel()); + } + + /// + /// Occurs when the view model requests that the owning dialog close. + /// + public event EventHandler? CloseRequested; + + /// + /// Gets the modification names available for selection. + /// + public IReadOnlyList ModificationNames { get; } + + /// + /// Gets or sets the currently selected modification name. + /// + public string? SelectedModificationName { get; set; } + + /// + /// Gets the command that accepts the current selection. + /// + public ICommand AcceptCommand { get; } + + /// + /// Gets the command that cancels the dialog. + /// + public ICommand CancelCommand { get; } + + /// + /// Gets the dialog result requested by the view model. + /// + public bool? DialogResult { get; private set; } + + /// + /// Accepts the current selection when one is available. + /// + private void AcceptSelection() + { + if (SelectedModificationName == null) + { + return; + } + + DialogResult = true; + CloseRequested?.Invoke(this, EventArgs.Empty); + } + + /// + /// Cancels the selection dialog. + /// + private void Cancel() + { + DialogResult = false; + CloseRequested?.Invoke(this, EventArgs.Empty); + } +} diff --git a/GenLauncherGO.UI/Features/Mods/ViewModels/InfoDialogKind.cs b/GenLauncherGO.UI/Features/Mods/ViewModels/InfoDialogKind.cs new file mode 100644 index 00000000..4ae5f638 --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ViewModels/InfoDialogKind.cs @@ -0,0 +1,22 @@ +namespace GenLauncherGO.UI.Features.Mods.ViewModels; + +/// +/// Describes the visual and action mode for a launcher information dialog. +/// +internal enum InfoDialogKind +{ + /// + /// A neutral information message with a single OK action. + /// + Info, + + /// + /// A blocking error message with a single OK action. + /// + Error, + + /// + /// A warning confirmation with continue and cancel actions. + /// + WarningConfirmation +} diff --git a/GenLauncherGO.UI/Features/Mods/ViewModels/InfoDialogViewModel.cs b/GenLauncherGO.UI/Features/Mods/ViewModels/InfoDialogViewModel.cs new file mode 100644 index 00000000..003cf204 --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ViewModels/InfoDialogViewModel.cs @@ -0,0 +1,190 @@ +using System; +using System.Windows; +using System.Windows.Input; +using GenLauncherGO.UI.Features.Dialogs.Models; +using GenLauncherGO.UI.Shared.Commands; + +namespace GenLauncherGO.UI.Features.Mods.ViewModels; + +/// +/// Provides bindable text, icon state, and actions for a launcher information dialog. +/// +internal sealed class InfoDialogViewModel +{ + /// + /// Initializes a new instance of the class. + /// + /// The dialog text and display options. + /// The dialog kind to display. + /// The optional replacement continue button text. + public InfoDialogViewModel( + LauncherInfoDialogRequest request, + InfoDialogKind kind, + string? continueText = null) + { + ArgumentNullException.ThrowIfNull(request); + + MainMessage = request.MainMessage; + DetailMessage = request.DetailMessage; + DetailFontSize = request.DetailFontSize ?? 18D; + ContinueText = string.IsNullOrWhiteSpace(continueText) ? null : continueText; + OkCommand = new RelayCommand(_ => Accept()); + CancelCommand = new RelayCommand(_ => Cancel()); + ContinueCommand = new RelayCommand(_ => Continue()); + CloseCommand = new RelayCommand(_ => Close()); + + IsWarningConfirmation = kind == InfoDialogKind.WarningConfirmation; + OkVisibility = kind == InfoDialogKind.WarningConfirmation ? Visibility.Hidden : Visibility.Visible; + ContinueVisibility = kind == InfoDialogKind.WarningConfirmation ? Visibility.Visible : Visibility.Hidden; + CancelVisibility = kind == InfoDialogKind.WarningConfirmation ? Visibility.Visible : Visibility.Hidden; + InfoIconVisibility = kind == InfoDialogKind.Info ? Visibility.Visible : Visibility.Hidden; + WarningIconVisibility = kind == InfoDialogKind.WarningConfirmation ? Visibility.Visible : Visibility.Hidden; + ErrorIconVisibility = kind == InfoDialogKind.Error ? Visibility.Visible : Visibility.Hidden; + } + + /// + /// Occurs when the view model requests that the owning dialog close. + /// + public event EventHandler? CloseRequested; + + /// + /// Gets the primary message text. + /// + public string MainMessage { get; } + + /// + /// Gets the secondary message text. + /// + public string DetailMessage { get; } + + /// + /// Gets the secondary message font size. + /// + public double DetailFontSize { get; } + + /// + /// Gets the optional replacement continue button text. + /// + public string? ContinueText { get; } + + /// + /// Gets the OK button visibility. + /// + public Visibility OkVisibility { get; } + + /// + /// Gets the continue button visibility. + /// + public Visibility ContinueVisibility { get; } + + /// + /// Gets the cancel button visibility. + /// + public Visibility CancelVisibility { get; } + + /// + /// Gets the information icon visibility. + /// + public Visibility InfoIconVisibility { get; } + + /// + /// Gets the warning icon visibility. + /// + public Visibility WarningIconVisibility { get; } + + /// + /// Gets the error icon visibility. + /// + public Visibility ErrorIconVisibility { get; } + + /// + /// Gets the command that accepts a plain information dialog. + /// + public ICommand OkCommand { get; } + + /// + /// Gets the command that cancels a warning confirmation. + /// + public ICommand CancelCommand { get; } + + /// + /// Gets the command that continues from a warning confirmation. + /// + public ICommand ContinueCommand { get; } + + /// + /// Gets the command that closes the dialog. + /// + public ICommand CloseCommand { get; } + + /// + /// Gets a value indicating whether the dialog should remain closeable by the modal result reader. + /// + public bool ShouldHideOnCloseRequest { get; private set; } + + /// + /// Gets a value indicating whether the user chose to continue. + /// + public bool ContinueLaunch { get; private set; } = true; + + /// + /// Gets a value indicating whether the user selected any option. + /// + public bool ChoseAnOption { get; private set; } + + /// + /// Gets a value indicating whether the dialog is a warning confirmation. + /// + private bool IsWarningConfirmation { get; } + + /// + /// Accepts a plain information dialog. + /// + private void Accept() + { + ChoseAnOption = true; + ShouldHideOnCloseRequest = false; + CloseRequested?.Invoke(this, EventArgs.Empty); + } + + /// + /// Cancels a warning confirmation. + /// + private void Cancel() + { + ContinueLaunch = false; + RequestWarningConfirmationClosure(); + } + + /// + /// Continues from a warning confirmation. + /// + private void Continue() + { + RequestWarningConfirmationClosure(); + } + + /// + /// Closes the dialog using the appropriate result for its kind. + /// + private void Close() + { + if (IsWarningConfirmation) + { + Cancel(); + return; + } + + Accept(); + } + + /// + /// Requests closure for a warning confirmation while preserving the legacy hide-then-read-result flow. + /// + private void RequestWarningConfirmationClosure() + { + ChoseAnOption = true; + ShouldHideOnCloseRequest = IsWarningConfirmation; + CloseRequested?.Invoke(this, EventArgs.Empty); + } +} diff --git a/GenLauncherGO.UI/Features/Mods/ViewModels/ManualAddModificationViewModel.cs b/GenLauncherGO.UI/Features/Mods/ViewModels/ManualAddModificationViewModel.cs new file mode 100644 index 00000000..e41de0ab --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ViewModels/ManualAddModificationViewModel.cs @@ -0,0 +1,375 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Text; +using System.Windows.Input; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Models; +using GenLauncherGO.UI.Shared.Commands; +using GenLauncherGO.UI.Shared.Localization; + +namespace GenLauncherGO.UI.Features.Mods.ViewModels; + +/// +/// Provides bindable manual import fields, validation, and actions for launcher content. +/// +internal sealed class ManualAddModificationViewModel : INotifyPropertyChanged +{ + /// + /// The resource key shown when the content name is missing. + /// + private const string MissingModificationNameKey = "EnterModName"; + + /// + /// The resource key shown when the version is missing. + /// + private const string MissingVersionKey = "EnterModVersion"; + + /// + /// The resource key shown when either field contains unsupported characters. + /// + private const string UnsupportedCharactersKey = "NameAndVersionValidSymbols"; + + /// + /// The resource key shown when the version does not include a number. + /// + private const string VersionMissingNumberKey = "VersionMustContainNumbers"; + + /// + /// The selected package files. + /// + private readonly IReadOnlyList _files; + + /// + /// The addon or patch parent content name when importing nested content. + /// + private readonly string? _parentContentName; + + /// + /// The localized string provider. + /// + private readonly ILauncherStringLocalizer _stringLocalizer; + + /// + /// The dialog service used to show validation errors. + /// + private readonly ILauncherDialogService _dialogService; + + /// + /// The entered modification, patch, or addon name. + /// + private string _modificationName; + + /// + /// The entered version. + /// + private string _version = string.Empty; + + /// + /// Initializes a new instance of the class. + /// + /// The selected package files. + /// The addon or patch parent content name when importing nested content. + /// The localized string provider. + /// The dialog service used to show validation errors. + public ManualAddModificationViewModel( + IReadOnlyList files, + string? parentContentName, + ILauncherStringLocalizer stringLocalizer, + ILauncherDialogService dialogService) + { + ArgumentNullException.ThrowIfNull(files); + + _files = files.ToList(); + _parentContentName = parentContentName; + _stringLocalizer = stringLocalizer ?? throw new ArgumentNullException(nameof(stringLocalizer)); + _dialogService = dialogService ?? throw new ArgumentNullException(nameof(dialogService)); + _modificationName = InferModificationName(_files); + AcceptCommand = new RelayCommand(_ => Accept(), _ => CanAccept); + CancelCommand = new RelayCommand(_ => Cancel()); + } + + /// + public event PropertyChangedEventHandler? PropertyChanged; + + /// + /// Occurs when the view model requests that the owning dialog close. + /// + public event EventHandler? CloseRequested; + + /// + /// Gets or sets the entered modification, patch, or addon name. + /// + public string ModificationName + { + get => _modificationName; + set + { + string newValue = value ?? string.Empty; + if (String.Equals(_modificationName, newValue, StringComparison.Ordinal)) + { + return; + } + + _modificationName = newValue; + NotifyInputChanged(nameof(ModificationName), nameof(ModificationNameValidationMessage)); + } + } + + /// + /// Gets or sets the entered version. + /// + public string Version + { + get => _version; + set + { + string newValue = value ?? string.Empty; + if (String.Equals(_version, newValue, StringComparison.Ordinal)) + { + return; + } + + _version = newValue; + NotifyInputChanged(nameof(Version), nameof(VersionValidationMessage)); + } + } + + /// + /// Gets the localized validation message for the content name. + /// + public string ModificationNameValidationMessage => + GetLocalizedValidationMessage(GetModificationNameValidationKey(ModificationName)); + + /// + /// Gets the localized validation message for the version. + /// + public string VersionValidationMessage => GetLocalizedValidationMessage(GetVersionValidationKey(Version)); + + /// + /// Gets a value indicating whether both import fields are valid. + /// + public bool CanAccept => + GetModificationNameValidationKey(ModificationName) == null && + GetVersionValidationKey(Version) == null; + + /// + /// Gets the command that validates and accepts the import details. + /// + public ICommand AcceptCommand { get; } + + /// + /// Gets the command that cancels the import dialog. + /// + public ICommand CancelCommand { get; } + + /// + /// Gets the entered import details after the dialog is accepted. + /// + public ManualModificationDialogResult? ImportResult { get; private set; } + + /// + /// Gets the dialog result requested by the view model. + /// + public bool? DialogResult { get; private set; } + + /// + /// Validates and accepts the entered import details. + /// + private void Accept() + { + string? validationKey = GetFirstValidationKey(); + if (validationKey != null) + { + ShowValidationError(validationKey); + return; + } + + ImportResult = new ManualModificationDialogResult( + _files, + _parentContentName, + ModificationName, + Version); + DialogResult = true; + CloseRequested?.Invoke(this, EventArgs.Empty); + } + + /// + /// Cancels the import dialog. + /// + private void Cancel() + { + DialogResult = false; + CloseRequested?.Invoke(this, EventArgs.Empty); + } + + /// + /// Infers a default content name from the first selected package file. + /// + /// The selected package file paths. + /// The inferred content name, or an empty string when no usable filename is available. + private static string InferModificationName(IReadOnlyList files) + { + string? firstFile = files.FirstOrDefault(file => !string.IsNullOrWhiteSpace(file)); + if (firstFile == null) + { + return string.Empty; + } + + string fileName = Path.GetFileNameWithoutExtension(firstFile); + if (string.IsNullOrWhiteSpace(fileName)) + { + return string.Empty; + } + + return NormalizeInferredName(fileName); + } + + /// + /// Replaces unsupported filename characters with a single space for a user-editable inferred name. + /// + /// The selected file name without its extension. + /// The normalized inferred name. + private static string NormalizeInferredName(string fileName) + { + StringBuilder builder = new(fileName.Length); + bool previousWasSpace = false; + + foreach (char character in fileName) + { + if (IsSupportedFieldCharacter(character)) + { + builder.Append(character); + previousWasSpace = char.IsWhiteSpace(character); + continue; + } + + if (!previousWasSpace) + { + builder.Append(' '); + previousWasSpace = true; + } + } + + return builder.ToString().Trim(); + } + + /// + /// Gets the first validation resource key that currently blocks accepting the dialog. + /// + /// The first validation resource key, or when all fields are valid. + private string? GetFirstValidationKey() + { + return GetModificationNameValidationKey(ModificationName) ?? GetVersionValidationKey(Version); + } + + /// + /// Gets the validation resource key for the content name. + /// + /// The content name value to validate. + /// The validation resource key, or when the value is valid. + private static string? GetModificationNameValidationKey(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return MissingModificationNameKey; + } + + return ContainsOnlySupportedFieldCharacters(value) + ? null + : UnsupportedCharactersKey; + } + + /// + /// Gets the validation resource key for the version. + /// + /// The version value to validate. + /// The validation resource key, or when the value is valid. + private static string? GetVersionValidationKey(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return MissingVersionKey; + } + + if (!value.Any(character => character >= '0' && character <= '9')) + { + return VersionMissingNumberKey; + } + + return ContainsOnlySupportedFieldCharacters(value) + ? null + : UnsupportedCharactersKey; + } + + /// + /// Converts a validation resource key into display text. + /// + /// The validation resource key, if validation failed. + /// The localized validation text, or an empty string when the field is valid. + private string GetLocalizedValidationMessage(string? validationKey) + { + return validationKey == null ? string.Empty : _stringLocalizer[validationKey]; + } + + /// + /// Determines whether all characters in a field value are supported. + /// + /// The field value to inspect. + /// when every character can be used in a manual content name or version. + private static bool ContainsOnlySupportedFieldCharacters(string value) + { + return value.All(IsSupportedFieldCharacter); + } + + /// + /// Determines whether a character is supported for manual content name and version fields. + /// + /// The character to inspect. + /// when the character is supported. + private static bool IsSupportedFieldCharacter(char character) + { + return char.IsLetterOrDigit(character) || + character == '_' || + character == '.' || + character == '@' || + character == '-' || + character == ' '; + } + + /// + /// Shows a localized validation error. + /// + /// The localized detail-message resource key. + private void ShowValidationError(string detailKey) + { + _dialogService.ShowError(new LauncherInfoDialogRequest( + _stringLocalizer["OperationAborted"], + _stringLocalizer[detailKey])); + } + + /// + /// Raises all property and command notifications affected by import field edits. + /// + /// The edited field property name. + /// The validation message property name. + private void NotifyInputChanged(string fieldPropertyName, string validationPropertyName) + { + OnPropertyChanged(fieldPropertyName); + OnPropertyChanged(validationPropertyName); + OnPropertyChanged(nameof(CanAccept)); + CommandManager.InvalidateRequerySuggested(); + } + + /// + /// Raises for a bindable property. + /// + /// The property name. + private void OnPropertyChanged([CallerMemberName] string? propertyName = null) + { + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); + } +} diff --git a/GenLauncherGO.UI/Features/Mods/ViewModels/ModificationTileImageProvider.cs b/GenLauncherGO.UI/Features/Mods/ViewModels/ModificationTileImageProvider.cs new file mode 100644 index 00000000..1f112dfb --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ViewModels/ModificationTileImageProvider.cs @@ -0,0 +1,294 @@ +using System; +using System.Globalization; +using System.IO; +using System.Windows; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.UI.Features.Mods.Contracts; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.UI.Features.Mods.ViewModels; + +/// +/// Loads WPF image sources for one modification tile. +/// +internal sealed class ModificationTileImageProvider +{ + /// + /// The factory used to load modification tile images. + /// + private readonly ModificationImageSourceFactory _imageSourceFactory; + + /// + /// The current launcher UI context. + /// + private readonly ILauncherModsContext _launcherContext; + + /// + /// The service used to inspect modification image files. + /// + private readonly IModificationImageFileService _modificationImageFileService; + + /// + /// The logger used to record tile image failures. + /// + private readonly ILogger _logger; + + /// + /// Determines whether WPF image loading is available. + /// + private readonly Func _canLoadImages; + + /// + /// Loads a cached tile image from disk. + /// + private readonly Func _loadFileImage; + + /// + /// Loads the default tile image for the active game. + /// + private readonly Func _loadDefaultImage; + + /// + /// The selected advertising image index for this tile instance. + /// + private int _advertisingImageIndex = -1; + + /// + /// Initializes a new instance of the class. + /// + /// The factory used to load modification tile images. + /// The current launcher UI context. + /// The service used to inspect modification image files. + /// The logger used to record tile image failures. + public ModificationTileImageProvider( + ModificationImageSourceFactory imageSourceFactory, + ILauncherModsContext launcherContext, + IModificationImageFileService modificationImageFileService, + ILogger logger) + : this( + imageSourceFactory, + launcherContext, + modificationImageFileService, + logger, + CanLoadImages, + (path, grayscale) => imageSourceFactory.LoadFileImage(path, grayscale), + (game, grayscale) => imageSourceFactory.LoadDefaultImage(game, grayscale)) + { + } + + /// + /// Initializes a new instance of the class with image loading adapters. + /// + /// The factory used to load modification tile images. + /// The current launcher UI context. + /// The service used to inspect modification image files. + /// The logger used to record tile image failures. + /// Determines whether image decoding should run. + /// Loads a cached tile image from disk. + /// Loads the default tile image for the active game. + internal ModificationTileImageProvider( + ModificationImageSourceFactory imageSourceFactory, + ILauncherModsContext launcherContext, + IModificationImageFileService modificationImageFileService, + ILogger logger, + Func canLoadImages, + Func loadFileImage, + Func loadDefaultImage) + { + _imageSourceFactory = imageSourceFactory ?? throw new ArgumentNullException(nameof(imageSourceFactory)); + _launcherContext = launcherContext ?? throw new ArgumentNullException(nameof(launcherContext)); + _modificationImageFileService = modificationImageFileService ?? + throw new ArgumentNullException(nameof(modificationImageFileService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _canLoadImages = canLoadImages ?? throw new ArgumentNullException(nameof(canLoadImages)); + _loadFileImage = loadFileImage ?? throw new ArgumentNullException(nameof(loadFileImage)); + _loadDefaultImage = loadDefaultImage ?? throw new ArgumentNullException(nameof(loadDefaultImage)); + } + + /// + /// Loads the grayscale image for a modification tile. + /// + /// The tile modification. + /// The latest known modification version. + /// A value indicating whether the modification is local-only. + /// The loaded image source, or when no image can be loaded. + public ImageSource? LoadGrayscaleImage( + GameModification modification, + ModificationVersion latestVersion, + bool localMod) + { + if (!_canLoadImages()) + { + return null; + } + + if (modification.ModificationType == ModificationType.Mod) + { + return LoadImage( + GetModificationImageFileName(modification, latestVersion, localMod), + grayscale: true, + useDefaultWhenMissing: true); + } + + if (modification.ModificationType == ModificationType.Advertising) + { + return LoadAdvertisingImage(modification); + } + + return null; + } + + /// + /// Loads the color image for a selected modification tile. + /// + /// The tile modification. + /// The latest known modification version. + /// A value indicating whether the modification is local-only. + /// The loaded image source, or when no image can be loaded. + public ImageSource? LoadColorImage( + GameModification modification, + ModificationVersion latestVersion, + bool localMod) + { + if (!_canLoadImages() || + modification.ModificationType != ModificationType.Mod) + { + return null; + } + + return LoadImage( + GetModificationImageFileName(modification, latestVersion, localMod), + grayscale: false, + useDefaultWhenMissing: true); + } + + /// + /// Loads an advertising tile image. + /// + /// The advertising tile modification. + /// The advertising image source, or when no image exists. + private ImageSource? LoadAdvertisingImage(GameModification modification) + { + string folderName = modification.Name.Trim(Path.GetInvalidFileNameChars()); + + int filesCount = _modificationImageFileService.CountImageFiles(folderName); + if (filesCount <= 0) + { + return null; + } + + if (_advertisingImageIndex == -1) + { + var random = new Random(); + int value = random.Next(0, 30); + _advertisingImageIndex = value == 0 + ? random.Next(filesCount / 2, filesCount) + : random.Next(0, filesCount / 2); + } + + string? imageFileName = _modificationImageFileService.FindExistingImageFilePath( + folderName, + _advertisingImageIndex.ToString(CultureInfo.InvariantCulture)); + + return LoadImage(imageFileName, grayscale: false, useDefaultWhenMissing: false); + } + + /// + /// Resolves the cached image file for the current modification version. + /// + /// The tile modification. + /// The latest known modification version. + /// A value indicating whether the modification is local-only. + /// The cached image path when one exists or should be tried. + private string? GetModificationImageFileName( + GameModification modification, + ModificationVersion latestVersion, + bool localMod) + { + string? imageFileName = _modificationImageFileService.FindExistingImageFilePath( + modification.Name, + latestVersion.Version); + + if (localMod && !_modificationImageFileService.ImageExists(imageFileName)) + { + return null; + } + + return imageFileName; + } + + /// + /// Loads a cached or default image for a modification tile. + /// + /// The cached image path. + /// A value indicating whether the image should be rendered in grayscale. + /// A value indicating whether a missing image should use the default image. + /// The loaded image source, or when no image can be loaded. + private ImageSource? LoadImage(string? path, bool grayscale, bool useDefaultWhenMissing) + { + try + { + BitmapSource? image = _modificationImageFileService.ImageExists(path) + ? _loadFileImage(path, grayscale) + : null; + + if (image == null && useDefaultWhenMissing) + { + image = _loadDefaultImage( + _launcherContext.CurrentlyManagedGame, + grayscale); + } + + return image; + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Could not load modification image {ImageFileName}; attempting to remove the cached image.", + Path.GetFileName(path)); + + return TryRemoveInvalidImage(path, grayscale, useDefaultWhenMissing); + } + } + + /// + /// Removes an invalid cached image and falls back to the default image when appropriate. + /// + /// The invalid cached image path. + /// A value indicating whether the fallback image should be grayscale. + /// A value indicating whether a missing image should use the default image. + /// The fallback image source, or when no fallback can be loaded. + private ImageSource? TryRemoveInvalidImage(string? path, bool grayscale, bool useDefaultWhenMissing) + { + try + { + _modificationImageFileService.TryDeleteImage(path); + + return useDefaultWhenMissing + ? _loadDefaultImage(_launcherContext.CurrentlyManagedGame, grayscale) + : null; + } + catch (Exception deleteException) + { + _logger.LogWarning( + deleteException, + "Could not remove invalid modification image {ImageFileName}.", + Path.GetFileName(path)); + return null; + } + } + + /// + /// Gets a value indicating whether the current process should perform WPF image decoding. + /// + /// when the WPF application is active; otherwise, . + private static bool CanLoadImages() + { + return Application.Current != null; + } +} diff --git a/GenLauncherGO.UI/Features/Mods/ViewModels/ModificationTileState.cs b/GenLauncherGO.UI/Features/Mods/ViewModels/ModificationTileState.cs new file mode 100644 index 00000000..6900b8f7 --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ViewModels/ModificationTileState.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.UI.Shared.Localization; + +namespace GenLauncherGO.UI.Features.Mods.ViewModels; + +/// +/// Owns non-visual state for one launcher modification tile. +/// +internal sealed class ModificationTileState +{ + private readonly ILauncherStringLocalizer _stringLocalizer; + + /// + /// Initializes a new instance of the class. + /// + /// The modification represented by the tile. + /// The localized string provider. + public ModificationTileState( + GameModification modification, + ILauncherStringLocalizer stringLocalizer) + { + Modification = modification ?? throw new ArgumentNullException(nameof(modification)); + _stringLocalizer = stringLocalizer ?? throw new ArgumentNullException(nameof(stringLocalizer)); + + RefreshFromModification(); + } + + /// + /// Gets the modification represented by the tile. + /// + public GameModification Modification { get; } + + /// + /// Gets the latest known version. + /// + public ModificationVersion LatestVersion { get; private set; } = null!; + + /// + /// Gets the selected version, or when no version exists. + /// + public ModificationVersion? SelectedVersion { get; private set; } + + /// + /// Gets the display name for the tile. + /// + public string NameInfo { get; private set; } = string.Empty; + + /// + /// Gets the display text for the latest version. + /// + public string LatestVersionInfo { get; private set; } = string.Empty; + + /// + /// Gets a value indicating whether the tile can be launched. + /// + public bool ReadyToRun { get; private set; } = true; + + /// + /// Gets a value indicating whether the modification has only local versions. + /// + public bool LocalMod { get; private set; } + + /// + /// Refreshes version state from the backing modification. + /// + public void RefreshFromModification() + { + NameInfo = Modification.Name; + + if (Modification.ModificationVersions.Count == 0) + { + SelectedVersion = null; + LatestVersionInfo = string.Empty; + return; + } + + if (Modification.ModificationType == ModificationType.Mod) + { + LocalMod = !Modification.ModificationVersions.Any(HasManagedPackageSource); + } + + SelectedVersion = GetSelectedVersion(); + if (SelectedVersion != null) + { + SelectedVersion.IsSelected = true; + } + + LatestVersion = Modification.ModificationVersions.OrderBy(version => version).Last(); + LatestVersionInfo = Modification.ModificationType == ModificationType.Advertising + ? LatestVersion.Version + : _stringLocalizer["LatestVersion"] + LatestVersion.Version; + } + + /// + /// Marks the tile as ready to launch. + /// + public void MarkReadyToRun() + { + ReadyToRun = true; + } + + /// + /// Marks the tile as not ready to launch. + /// + public void MarkNotReadyToRun() + { + ReadyToRun = false; + } + + /// + /// Selects the latest installed version after a successful install. + /// + /// The selected installed version. + public ModificationVersion SelectLatestInstalledVersion() + { + if (SelectedVersion != null) + { + SelectedVersion.IsSelected = false; + } + + SelectedVersion = Modification.ModificationVersions.Where(version => version.Installed).Last(); + SelectedVersion.IsSelected = true; + ReadyToRun = true; + return SelectedVersion; + } + + private static bool HasManagedPackageSource(ModificationReposVersion version) + { + return !string.IsNullOrEmpty(version.S3BucketName) || + !string.IsNullOrEmpty(version.SimpleDownloadLink) || + !string.IsNullOrEmpty(version.S3FolderName); + } + + private ModificationVersion? GetSelectedVersion() + { + if (Modification.ModificationVersions.Count == 0) + { + return null; + } + + ModificationVersion? selectedVersion = Modification.ModificationVersions + .Where(version => version.Installed) + .FirstOrDefault(version => version.IsSelected); + + if (selectedVersion != null) + { + return selectedVersion; + } + + selectedVersion = Modification.ModificationVersions + .Where(version => version.Installed) + .OrderBy(version => version) + .FirstOrDefault(); + + return selectedVersion ?? Modification.ModificationVersions.OrderBy(version => version).FirstOrDefault(); + } +} diff --git a/GenLauncherGO.UI/Features/Mods/Views/AddModificationWindow.xaml b/GenLauncherGO.UI/Features/Mods/Views/AddModificationWindow.xaml new file mode 100644 index 00000000..03b00b4e --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/Views/AddModificationWindow.xaml @@ -0,0 +1,32 @@ + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - -