diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 00000000..d17d1cff --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,2 @@ +[mcp_servers.avalonia-docs] +url = "https://docs-mcp.avaloniaui.net/mcp" diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 00000000..0ac8ae35 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,20 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-reportgenerator-globaltool": { + "version": "5.5.10", + "commands": [ + "reportgenerator" + ], + "rollForward": false + }, + "dotnet-stryker": { + "version": "4.16.0", + "commands": [ + "dotnet-stryker" + ], + "rollForward": false + } + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..decae146 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,673 @@ +root = true + +############################################################################### +# GenLauncherGO code style +# +# Sources, in precedence order: +# 1. Microsoft .NET/C# coding conventions and the Framework Design Guidelines +# naming rules. +# 2. The C# formatting options at their documented defaults, which are also +# what the wider .NET ecosystem sees. +# 3. Readability, where the guidelines are silent or explicitly neutral. +# +# Enforcement: +# Every gated rule below is an `error`, enforced by `dotnet build` through +# EnforceCodeStyleInBuild (see Directory.Build.props). Nothing here relies on +# a specific IDE, so an outside contributor using any editor gets identical +# results from the command line. +# +# Convention used in this file: +# Option keys carry the *preference* only. Severity lives exclusively in +# `dotnet_diagnostic..severity`, or in the bulk +# `dotnet_analyzer_diagnostic.category-.severity` form used by the code +# quality section. Do not use the trailing `option = value:severity` form: +# where both are present `dotnet_diagnostic` silently wins, which makes rules +# look enforced when they are inert. +# +# Deliberate opt-outs are grouped at the end of the severity section with the +# reason recorded. Anything not listed runs at its built-in default. +############################################################################### + +[*] +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 + +[*.{xaml,axaml}] +indent_size = 4 + +[*.cs] + +#### Using directives #### + +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false +csharp_using_directive_placement = outside_namespace + +#### Formatting: new lines #### +# Allman braces. The documented default and the near-universal .NET style. + +csharp_new_line_before_open_brace = all +csharp_new_line_before_else = true +csharp_new_line_before_catch = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_between_query_expression_clauses = true + +#### Formatting: indentation #### + +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = false +csharp_indent_switch_labels = true +csharp_indent_labels = one_less_than_current +csharp_indent_block_contents = true +csharp_indent_braces = false + +#### Formatting: spacing #### + +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +#### Formatting: wrapping #### +# Braces are mandatory (IDE0011), so preserving an author's single-line form is +# only ever `if (x) { return; }`, which stays readable. Keep the defaults. + +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Style: this/Me qualification #### + +dotnet_style_qualification_for_event = false +dotnet_style_qualification_for_field = false +dotnet_style_qualification_for_method = false +dotnet_style_qualification_for_property = false + +#### Style: language keywords over framework types #### + +dotnet_style_predefined_type_for_locals_parameters_members = true +dotnet_style_predefined_type_for_member_access = true + +#### Style: var #### +# Explicit types except where the right-hand side already states the type. +# This follows the C# coding conventions doc. Note the dotnet/runtime repo uses +# `var` more liberally; the doc is the better citation and the clearer read. + +csharp_style_var_for_built_in_types = false +csharp_style_var_when_type_is_apparent = true +csharp_style_var_elsewhere = false + +#### Style: expression-bodied members #### +# Allowed where the body is genuinely an expression, disallowed where it hides +# a method's shape. + +csharp_style_expression_bodied_accessors = true +csharp_style_expression_bodied_constructors = false +csharp_style_expression_bodied_indexers = true +csharp_style_expression_bodied_lambdas = true +csharp_style_expression_bodied_local_functions = false +csharp_style_expression_bodied_methods = false +csharp_style_expression_bodied_operators = false +csharp_style_expression_bodied_properties = true + +#### Style: pattern matching #### + +csharp_style_pattern_matching_over_as_with_null_check = true +csharp_style_pattern_matching_over_is_with_cast_check = true +csharp_style_prefer_extended_property_pattern = true +csharp_style_prefer_not_pattern = true +csharp_style_prefer_pattern_matching = true +csharp_style_prefer_switch_expression = true + +#### Style: null checking #### + +csharp_style_conditional_delegate_call = true +csharp_style_prefer_null_check_over_type_check = true +csharp_style_throw_expression = true +dotnet_style_coalesce_expression = true +dotnet_style_null_propagation = true +dotnet_style_prefer_is_null_check_over_reference_equality_method = true + +#### Style: expression level #### + +# Only fires where the target type is already spelled out on the line, so the +# type still appears exactly once. Consistent with the explicit-type preference. +csharp_style_implicit_object_creation_when_type_is_apparent = true + +dotnet_style_collection_initializer = true +dotnet_style_object_initializer = true +dotnet_style_prefer_auto_properties = true +dotnet_style_prefer_compound_assignment = true +dotnet_style_prefer_inferred_anonymous_type_member_names = true +dotnet_style_prefer_inferred_tuple_names = true +dotnet_style_prefer_simplified_boolean_expressions = true +dotnet_style_prefer_simplified_interpolation = true + +# Ternaries read poorly once the branches are non-trivial, and the analyzer +# cannot judge that. Prefer an explicit if/else. +dotnet_style_prefer_conditional_expression_over_assignment = false +dotnet_style_prefer_conditional_expression_over_return = false + +#### Style: collection expressions #### +# `[]` and `[1, 2, 3]` are strictly clearer than `Array.Empty()` and +# `new[] { 1, 2, 3 }`. `when_types_exactly_match` keeps the conversion to cases +# where the target type is unambiguous; see IDE0305 under opt-outs for the +# fluent form, which is deliberately excluded. + +dotnet_style_prefer_collection_expression = when_types_exactly_match + +#### Style: modifiers, fields, parameters #### + +csharp_prefer_static_local_function = true +csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async +csharp_style_prefer_readonly_struct = true +csharp_style_prefer_readonly_struct_member = true +dotnet_style_readonly_field = true +dotnet_style_require_accessibility_modifiers = for_non_interface_members + +# Unused parameters mislead every future reader, not just external callers. +dotnet_code_quality_unused_parameters = all + +#### Style: code block and namespace shape #### + +csharp_prefer_braces = true +csharp_prefer_simple_using_statement = true +csharp_style_namespace_declarations = file_scoped +csharp_style_prefer_method_group_conversion = true +dotnet_style_namespace_match_folder = true + +# Primary constructors on non-record classes hide the field declarations and +# silently capture parameters for the object's lifetime. Declare fields. +csharp_style_prefer_primary_constructors = false + +############################################################################### +# Naming +# +# Framework Design Guidelines naming conventions. Rules are matched by symbol +# specificity rather than file order, but they are still listed most-specific +# first so the intent survives a move to older tooling. +############################################################################### + +#### Styles #### + +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.camel_case.capitalization = camel_case + +dotnet_naming_style.underscore_camel_case.required_prefix = _ +dotnet_naming_style.underscore_camel_case.capitalization = camel_case + +dotnet_naming_style.prefix_interface_with_i.required_prefix = I +dotnet_naming_style.prefix_interface_with_i.capitalization = pascal_case + +dotnet_naming_style.prefix_type_parameter_with_t.required_prefix = T +dotnet_naming_style.prefix_type_parameter_with_t.capitalization = pascal_case + +dotnet_naming_style.suffix_async.required_suffix = Async +dotnet_naming_style.suffix_async.capitalization = pascal_case + +#### Rules #### + +# Constants, including private ones, are PascalCase rather than _camelCase. +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 = error +dotnet_naming_symbols.constants.applicable_kinds = field +dotnet_naming_symbols.constants.required_modifiers = const + +dotnet_naming_rule.local_constants_are_pascal_case.symbols = local_constants +dotnet_naming_rule.local_constants_are_pascal_case.style = pascal_case +dotnet_naming_rule.local_constants_are_pascal_case.severity = error +dotnet_naming_symbols.local_constants.applicable_kinds = local +dotnet_naming_symbols.local_constants.required_modifiers = const + +# Only catches methods declared `async`. A Task-returning method without the +# keyword cannot be matched by a naming rule, because rules cannot inspect +# return types. That gap needs CA-family analysis, not this file. +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 = error +dotnet_naming_symbols.async_methods.applicable_kinds = method +dotnet_naming_symbols.async_methods.required_modifiers = async + +dotnet_naming_rule.local_functions_are_pascal_case.symbols = local_functions +dotnet_naming_rule.local_functions_are_pascal_case.style = pascal_case +dotnet_naming_rule.local_functions_are_pascal_case.severity = error +dotnet_naming_symbols.local_functions.applicable_kinds = local_function + +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 = error +dotnet_naming_symbols.interfaces.applicable_kinds = interface + +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 = error +dotnet_naming_symbols.type_parameters.applicable_kinds = type_parameter + +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 = error +dotnet_naming_symbols.types.applicable_kinds = class, struct, enum, delegate + +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 = error +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 = error +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 = error +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private + +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 = error +dotnet_naming_symbols.parameters.applicable_kinds = parameter + +dotnet_naming_rule.locals_are_camel_case.symbols = locals +dotnet_naming_rule.locals_are_camel_case.style = camel_case +dotnet_naming_rule.locals_are_camel_case.severity = error +dotnet_naming_symbols.locals.applicable_kinds = local + +############################################################################### +# Severities +# +# Everything here is an error so that a violation fails `dotnet build` for +# every contributor and every agent, with no IDE and no extra tooling. +############################################################################### + +#### Formatting #### +# IDE0055 is the umbrella for every csharp_space_*/new_line_*/indent_* option +# above, which is what makes formatting a build error rather than something +# only `dotnet format` would notice. +dotnet_diagnostic.IDE0055.severity = error + +#### Naming #### +dotnet_diagnostic.IDE1006.severity = error + +#### Unnecessary code #### +dotnet_diagnostic.IDE0005.severity = error +dotnet_diagnostic.IDE0035.severity = error +dotnet_diagnostic.IDE0051.severity = error +dotnet_diagnostic.IDE0052.severity = error +dotnet_diagnostic.IDE0059.severity = error +dotnet_diagnostic.IDE0060.severity = error +dotnet_diagnostic.IDE0080.severity = error +dotnet_diagnostic.IDE0100.severity = error +dotnet_diagnostic.IDE0110.severity = error + +#### Code block and namespace shape #### +dotnet_diagnostic.IDE0011.severity = error +dotnet_diagnostic.IDE0040.severity = error +dotnet_diagnostic.IDE0044.severity = error +dotnet_diagnostic.IDE0063.severity = error +dotnet_diagnostic.IDE0065.severity = error +dotnet_diagnostic.IDE0130.severity = error +dotnet_diagnostic.IDE0161.severity = error + +#### this/Me qualification and type references #### +dotnet_diagnostic.IDE0003.severity = error +dotnet_diagnostic.IDE0009.severity = error +dotnet_diagnostic.IDE0049.severity = error + +#### var #### +dotnet_diagnostic.IDE0007.severity = error +dotnet_diagnostic.IDE0008.severity = error + +#### Expression-bodied members #### +dotnet_diagnostic.IDE0021.severity = error +dotnet_diagnostic.IDE0022.severity = error +dotnet_diagnostic.IDE0023.severity = error +dotnet_diagnostic.IDE0024.severity = error +dotnet_diagnostic.IDE0025.severity = error +dotnet_diagnostic.IDE0026.severity = error +dotnet_diagnostic.IDE0027.severity = error +dotnet_diagnostic.IDE0053.severity = error +dotnet_diagnostic.IDE0061.severity = error + +#### Pattern matching #### +dotnet_diagnostic.IDE0019.severity = error +dotnet_diagnostic.IDE0020.severity = error +dotnet_diagnostic.IDE0038.severity = error +dotnet_diagnostic.IDE0066.severity = error +dotnet_diagnostic.IDE0078.severity = error +dotnet_diagnostic.IDE0083.severity = error +dotnet_diagnostic.IDE0170.severity = error + +#### Null checking #### +dotnet_diagnostic.IDE0016.severity = error +dotnet_diagnostic.IDE0029.severity = error +dotnet_diagnostic.IDE0030.severity = error +dotnet_diagnostic.IDE0031.severity = error +dotnet_diagnostic.IDE0041.severity = error +dotnet_diagnostic.IDE0150.severity = error +dotnet_diagnostic.IDE0270.severity = error +dotnet_diagnostic.IDE1005.severity = error + +#### Expression level #### +dotnet_diagnostic.IDE0017.severity = error +dotnet_diagnostic.IDE0018.severity = error +dotnet_diagnostic.IDE0032.severity = error +dotnet_diagnostic.IDE0033.severity = error +dotnet_diagnostic.IDE0034.severity = error +dotnet_diagnostic.IDE0037.severity = error +dotnet_diagnostic.IDE0039.severity = error +dotnet_diagnostic.IDE0054.severity = error +dotnet_diagnostic.IDE0056.severity = error +dotnet_diagnostic.IDE0057.severity = error +dotnet_diagnostic.IDE0071.severity = error +dotnet_diagnostic.IDE0074.severity = error +dotnet_diagnostic.IDE0075.severity = error +dotnet_diagnostic.IDE0082.severity = error +dotnet_diagnostic.IDE0090.severity = error +dotnet_diagnostic.IDE0180.severity = error +dotnet_diagnostic.IDE0200.severity = error +dotnet_diagnostic.IDE0330.severity = error + +#### Collection expressions #### +dotnet_diagnostic.IDE0028.severity = error +dotnet_diagnostic.IDE0300.severity = error +dotnet_diagnostic.IDE0301.severity = error +dotnet_diagnostic.IDE0302.severity = error +dotnet_diagnostic.IDE0303.severity = error +dotnet_diagnostic.IDE0304.severity = error +dotnet_diagnostic.IDE0306.severity = error + +#### Modifiers #### +dotnet_diagnostic.IDE0036.severity = error +dotnet_diagnostic.IDE0062.severity = error +dotnet_diagnostic.IDE0064.severity = error +dotnet_diagnostic.IDE0250.severity = error +dotnet_diagnostic.IDE0251.severity = error + +#### XML documentation #### +# Documentation is required for cross-project contracts and non-obvious +# behavior, not for every member, but what exists must be valid. +dotnet_diagnostic.CS1570.severity = error +dotnet_diagnostic.CS1571.severity = error +dotnet_diagnostic.CS1572.severity = error +dotnet_diagnostic.CS1573.severity = error +dotnet_diagnostic.CS1574.severity = error + +#### Deliberate opt-outs #### + +# IDE0305 rewrites `source.ToArray()` as `[.. source]`. The spread form is +# harder to read than the call it replaces, which loses on the readability +# tiebreaker even though the sibling collection-expression rules win on it. +dotnet_diagnostic.IDE0305.severity = none + +# IDE0042 pushes `var (name, value) = pair`, which hides both types and +# contradicts the explicit-type preference set above. +dotnet_diagnostic.IDE0042.severity = none + +# IDE0058 flags every ignored return value, including the fluent builder and +# TryAdd patterns where discarding is intended. Too noisy to gate on. +dotnet_diagnostic.IDE0058.severity = none + +# IDE0046/IDE0045 are the diagnostics behind the two +# prefer_conditional_expression preferences disabled above. +dotnet_diagnostic.IDE0045.severity = none +dotnet_diagnostic.IDE0046.severity = none + +# IDE0290 is the primary-constructor suggestion, disabled by preference above. +dotnet_diagnostic.IDE0290.severity = none + +############################################################################### +# Code quality +# +# Which categories run is selected in Directory.Build.props. The bulk +# `dotnet_analyzer_diagnostic.category-*` entries here raise every rule in those +# categories to `error`: enabling a category leaves each rule at its own default +# severity, and several default to a suggestion, which would be enabled but not +# gating. +# +# Design and Naming deliberately keep their default rule sets instead. Several +# of their rules are written for reusable libraries and contradict this +# repository's own gates: CA1032 and CA1064 require members nothing calls, which +# the unused-public-API rule forbids; CA1014 is meaningless without external +# consumers; CA1062 is redundant under `Nullable=enable`; CA1031 and CA1060 +# would force 95 boundary catches and four P/Invoke files to be restructured for +# a legacy convention. The rules worth having are opted into individually. +############################################################################### + +#### Enabled categories #### +dotnet_analyzer_diagnostic.category-Documentation.severity = error +dotnet_analyzer_diagnostic.category-Globalization.severity = error +dotnet_analyzer_diagnostic.category-Interoperability.severity = error +dotnet_analyzer_diagnostic.category-Maintainability.severity = error +dotnet_analyzer_diagnostic.category-Reliability.severity = error +dotnet_analyzer_diagnostic.category-Security.severity = error +dotnet_analyzer_diagnostic.category-SingleFile.severity = error +dotnet_analyzer_diagnostic.category-Usage.severity = error + +#### Design opt-ins #### +dotnet_diagnostic.CA1052.severity = error +dotnet_diagnostic.CA1058.severity = error +dotnet_diagnostic.CA1061.severity = error +dotnet_diagnostic.CA1063.severity = error +dotnet_diagnostic.CA1065.severity = error +dotnet_diagnostic.CA1068.severity = error +dotnet_diagnostic.CA1069.severity = error + +#### Naming opt-ins #### +# IDE1006 already covers casing and prefixes. These add what a naming style +# cannot express: underscores and enum member prefixes. +dotnet_diagnostic.CA1707.severity = error +dotnet_diagnostic.CA1712.severity = error + +#### Performance opt-ins #### +# The full Performance set is mostly micro-optimization that a launcher cannot +# measure — char overloads of string methods and similar — where each rule buys a +# build break for a stylistic preference. These two are different: both describe +# defects rather than inefficiencies. +# +# CA1851 catches enumerating a lazy sequence more than once, which changes results +# as well as cost. CA1862 catches case-insensitive comparison written without a +# StringComparison, the classic source of Windows path and culture bugs. +dotnet_diagnostic.CA1851.severity = error +dotnet_diagnostic.CA1862.severity = error + +#### Deliberate opt-outs #### + +# CA1848 (LoggerMessage delegates) and CA1873 (guard log calls with IsEnabled) +# are throughput rules for high-volume server logging. This launcher writes a +# handful of lines per user action, so both trade readable diagnostics for an +# unmeasurable gain: CA1873 alone would wrap 100 call sites in IsEnabled checks +# to avoid a Path.GetFileName. +dotnet_diagnostic.CA1848.severity = none +dotnet_diagnostic.CA1873.severity = none + +# CA1859 asks for concrete types in place of interfaces for speed. The gain is +# unmeasurable in a launcher and the change widens coupling to implementations, +# against the interface-boundary rule in AGENTS.md. +dotnet_diagnostic.CA1859.severity = none + +# The rules below cannot see enough to judge the code they flag, so they bill +# their cost as a growing list of per-site exceptions. A rule that has to be +# argued with at each call site is not a gate, so they are off rather than +# carried with carve-outs. +# +# CA1812 reports types that nothing constructs in source. The YAML and JSON +# readers construct their bound types through reflection, which it cannot see, +# so it reports live binding fixtures as dead code. +dotnet_diagnostic.CA1812.severity = none + +# CA2000 cannot follow dispose ownership once it leaves the creating method, and +# its own message documents the workarounds that fact requires. It reported the +# deliberately long-lived shared HttpClient and cached theme bitmap alongside a +# genuine leak, at a roughly even rate. The real leaks it found were fixed; the +# rule is off because sorting its output is a per-site argument, not a gate. +# CA2025, which is precise about a narrower hazard, stays on. +dotnet_diagnostic.CA2000.severity = none + +# CA2213 shares that blind spot for fields. Its only finding here was the logger +# factory resolved from the service provider, which the provider disposes; doing +# it again in the holding type would be a double dispose asserting an ownership +# the type does not have. +dotnet_diagnostic.CA2213.severity = none + +# The rules below ask for changes that cost more than the finding is worth here. +# Each was measured against this codebase rather than judged on reputation, and +# each would trade a real guarantee for a benefit this project does not collect. +# +# CA1849 wants FileStream.Flush(true) replaced with FlushAsync(). They are not +# equivalent: Flush(true) forces the operating system buffer to disk, which is +# the durability step the atomic writer exists to provide, and FlushAsync() does +# not. The sync-over-async calls it correctly identified elsewhere were fixed +# before switching it off. +dotnet_diagnostic.CA1849.severity = none + +# SYSLIB1054 and SYSLIB1096 convert DllImport and ComImport to the source +# generated forms. Both change marshalling behavior, and the code they land on is +# the hard-link, process-launch, and shell interop that AGENTS.md calls out as +# safety critical. Their payoff is trimming and AOT compatibility, which this +# launcher does not use: the supported publish sets PublishTrimmed=false. +dotnet_diagnostic.SYSLIB1054.severity = none +dotnet_diagnostic.SYSLIB1096.severity = none + +# CA1515 would make Avalonia controls, a XAML markup extension, and the generated +# Strings.cs internal. That risks breaking XAML type resolution and test +# discovery, and editing a generated designer file is forbidden by the UI +# guidance in any case. +dotnet_diagnostic.CA1515.severity = none + +# CA2007 breaks `dotnet format`. Its code fix appends .ConfigureAwait(false) to +# the initializer of an `await using` declaration, where the diagnostic is really +# about the disposal; that loses the target type of `new(...)` and changes the +# variable's type, so a plain `dotnet format` run left seven files uncompilable. +# A rule whose own fixer corrupts the source on a standard command is a trap for +# anyone contributing, whatever its merits. The ConfigureAwait calls it correctly +# prompted in Core and Infrastructure were kept. +dotnet_diagnostic.CA2007.severity = none + +# CA2016 breaks `dotnet format` the same way. Asked to forward a cancellation +# token into `Task.Run(() => ...)` inside a test that has no token in scope, its +# fixer writes `_` as the argument, which is not an identifier that exists there, +# and the file stops compiling. +dotnet_diagnostic.CA2016.severity = none + +# CA1826 replaces `list.FirstOrDefault()` with a `Count > 0 ? list[0] : null` +# conditional to save an enumerator allocation. Across the twelve call sites here +# that is a plainly worse read for a cost this launcher cannot measure, and most +# of them continue into `?.Something`, which the rewrite makes harder to follow. +dotnet_diagnostic.CA1826.severity = none + +# CA1806 reports an ignored return value for `Action act = () => new Thing(...)`, +# the standard way to assert that a constructor rejects its arguments. The value +# is meant to be discarded; the throw is the subject of the test. +dotnet_diagnostic.CA1806.severity = none + +# CA1838 wants the StringBuilder out of the GetFinalPathNameByHandle signature for +# cheaper marshalling. That buffer backs the path-safety primitives, and the same +# reasoning as SYSLIB1054 applies: a marshalling change to containment interop for +# a cost this launcher never pays. +dotnet_diagnostic.CA1838.severity = none + +# CA1822 is the most expensive rule measured here, and none of the cost is in the +# code it flags. Marking a member static breaks every instance-qualified call +# site, including ones in other projects, and the analyzer reports none of them: +# adopting it produced four rounds of CS0176 across the solution, one of which hid +# every diagnostic in two projects until it was cleared. A launcher collects +# nothing measurable in return. Held off rather than paid for repeatedly. +dotnet_diagnostic.CA1822.severity = none + +# CA1725 requires a parameter name to match the base declaration, which means +# adopting whatever the framework chose even when it reads worse. It renamed +# `eventArgs` to `e` on an override, and consistency with a single-letter name is +# not worth a less descriptive signature. +dotnet_diagnostic.CA1725.severity = none + + +# CA1308 prefers uppercase normalization for round-trip safety. It cannot tell +# that LauncherContentKey.ToStableString formats an identity already persisted +# in launcher-owned integrity records, where changing case invalidates every +# existing record. +dotnet_diagnostic.CA1308.severity = none + +# CA5394 reports every use of Random. It cannot tell that the only use here +# picks which advertising banner a mod tile shows. +dotnet_diagnostic.CA5394.severity = none + +# CA5351 reports every use of MD5. The one use here is fixed by the remote +# manifest contract, because S3 ETags are MD5, and detects an incomplete or +# corrupted download rather than guarding a security boundary. Everything else +# hashes with SHA-256. Note the cost of switching this off: a genuine MD5 +# misuse added later is no longer caught, so this is the one exclusion worth +# revisiting if the launcher ever hashes for authentication. +dotnet_diagnostic.CA5351.severity = none + +############################################################################### +# Project-scoped exceptions +# +# These draw a boundary between projects, which is a fact about the architecture +# rather than a per-site argument with the rule. A contributor reads them once. +############################################################################### + +[GenLauncherGO.Tests/**.cs] + +# These xUnit rules define whether a test is discoverable and distinct. Most are +# warnings already promoted by TreatWarningsAsErrors; listing them explicitly +# keeps the test contract stable if an analyzer changes its default severity. +dotnet_diagnostic.xUnit1004.severity = error +dotnet_diagnostic.xUnit1013.severity = error +dotnet_diagnostic.xUnit1025.severity = error +dotnet_diagnostic.xUnit1026.severity = error + +# GLT001 requires `MemberOrBehavior_ExpectedOutcome` or +# `MemberOrBehavior_Scenario_ExpectedOutcome` for every xUnit test method. +dotnet_diagnostic.GLT001.severity = error + +# Behavior-oriented test names use underscores to separate their semantic parts. +dotnet_diagnostic.CA1707.severity = none + +# CA1861 hoists inline arrays into `static readonly` fields to avoid reallocating +# them, which it qualifies with "if the called method is called repeatedly". A +# test method runs once, so the trade is all cost: the expected values move away +# from the assertion that reads them. +dotnet_diagnostic.CA1861.severity = none + +############################################################################### +# File-scoped exceptions +############################################################################### + +# This transport type mirrors the legacy backend's YAML keys character for +# character, so its members cannot follow the naming rules. Scoped here rather +# than with a #pragma because Rider honors an .editorconfig severity but not the +# pragma, which otherwise leaves the rule looking violated in the IDE while the +# build is clean. +[GenLauncherGO.Infrastructure/Mods/Models/LegacyLauncherCatalogDocument.cs] +dotnet_diagnostic.IDE1006.severity = none diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..0fcea614 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,13 @@ +# Line endings are declared in .editorconfig (end_of_line = crlf) and enforced +# on C# by `dotnet format whitespace`. Without this file the working-tree result +# depends on each contributor's core.autocrlf setting, so the same checkout can +# pass locally and fail the format gate in CI. +# +# Everything text-like is stored as LF and checked out as CRLF, which matches +# what the index already contains, so this changes no stored content. +* text=auto eol=crlf + +# Binary assets must never be normalized or diffed as text. +*.png binary +*.jpg binary +*.ico binary 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/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..dc090e10 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,50 @@ +# Package versions are centralized in Directory.Packages.props, which Dependabot +# updates in place. Two things here are deliberately not covered and stay manual: +# the SDK pin in global.json, and the tool pin in .config/dotnet-tools.json. +# +# Updates are grouped so a framework arrives as one reviewable pull request +# rather than one per package. Every pull request still has to clear the full +# build, which treats style, naming, and code quality violations as errors, so a +# bump that introduces a new diagnostic will fail until it is dealt with. +version: 2 + +updates: + - package-ecosystem: nuget + directory: "/" + schedule: + interval: weekly + day: monday + open-pull-requests-limit: 5 + commit-message: + prefix: chore + include: scope + groups: + avalonia: + patterns: + - "Avalonia*" + serilog: + patterns: + - "Serilog*" + microsoft-extensions: + patterns: + - "Microsoft.Extensions.*" + test-tooling: + patterns: + - "coverlet.*" + - "FluentAssertions" + - "Microsoft.NET.Test.Sdk" + - "NSubstitute*" + - "xunit*" + + - package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly + day: monday + commit-message: + prefix: ci + include: scope + groups: + actions: + patterns: + - "*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..9f61404e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,126 @@ +name: CI + +on: + push: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_NOLOGO: true + # Normalizes source paths in the produced assemblies, which is what makes the + # Deterministic setting in Directory.Build.props reproducible off this machine. + ContinuousIntegrationBuild: true + +jobs: + build-and-test: + name: Build, test, and verify coverage + runs-on: windows-2025 + timeout-minutes: 20 + env: + GENLAUNCHERGO_REQUIRE_SYMBOLIC_LINK_TESTS: true + + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Set up .NET + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 + with: + global-json-file: global.json + + - name: Restore + run: dotnet restore .\GenLauncherGO.sln + + # Style, naming, and code quality rules are `error` in .editorconfig and + # run through EnforceCodeStyleInBuild, so the build itself is the primary + # gate. + - name: Build + run: dotnet build .\GenLauncherGO.sln --configuration Release --no-restore + + # Runs the whitespace, style, and analyzer formatters. Not redundant with + # the build: whitespace and end-of-line violations are reported here only, + # as are style fixers that have no build-time implementation. Configuration + # independent, so once is enough. + - name: Verify formatting + run: dotnet format .\GenLauncherGO.sln --verify-no-changes --no-restore + + - name: Test with coverage backstop + run: dotnet msbuild .\eng\coverage.proj -target:Coverage -property:Configuration=Release -property:SkipCoverageBuild=true + + # Mutation testing is the gate for whether behavior is actually asserted; the coverage + # backstop above only proves a line executed. One job per production area: fanning them out + # keeps each area's break threshold enforceable on its own and keeps wall-clock bounded, and + # fail-fast is off so a regression in one area does not hide the state of the others. + # + # GenLauncherGO.UI is absent by necessity, not oversight. Avalonia emits InitializeComponent + # and the x:Name backing fields from a Roslyn source generator; Stryker recompiles from + # parsed syntax trees without running generators, so every .axaml.cs fails to compile and the + # run aborts. See eng/mutation.proj. + mutation-quality: + name: Mutation quality (${{ matrix.area }}) + runs-on: windows-2025 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + area: + - core + - infrastructure-common + - infrastructure-integrity + - infrastructure-launching + - infrastructure-mods + - infrastructure-platform + - infrastructure-updating + env: + GENLAUNCHERGO_REQUIRE_SYMBOLIC_LINK_TESTS: true + + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Set up .NET + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 + with: + global-json-file: global.json + + - name: Restore + run: dotnet restore .\GenLauncherGO.sln + + - name: Test mutations + run: dotnet msbuild .\eng\mutation.proj -target:Mutation -property:MutationConfiguration=${{ matrix.area }} + + publish-smoke-test: + name: Publish smoke test + runs-on: windows-2025 + timeout-minutes: 20 + + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Set up .NET + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 + with: + global-json-file: global.json + + - name: Publish supported single-file executable + shell: pwsh + run: | + $publishDirectory = Join-Path $env:RUNNER_TEMP "GenLauncherGO-publish" + dotnet publish .\GenLauncherGO.UI\GenLauncherGO.UI.csproj -p:PublishProfile=WinX64SelfContained --output $publishDirectory + $launcherPath = Join-Path $publishDirectory "GenLauncherGO.exe" + if (-not (Test-Path -LiteralPath $launcherPath -PathType Leaf)) { + throw "Expected the supported launcher executable at $launcherPath." + } + $looseLibraries = @(Get-ChildItem -LiteralPath $publishDirectory -Filter "*.dll" -File -Recurse) + if ($looseLibraries.Count -ne 0) { + throw "The supported single-file publish unexpectedly produced loose DLL files." + } diff --git a/.github/workflows/dependency-audit.yml b/.github/workflows/dependency-audit.yml new file mode 100644 index 00000000..82055f56 --- /dev/null +++ b/.github/workflows/dependency-audit.yml @@ -0,0 +1,58 @@ +name: Dependency audit + +on: + schedule: + # Weekly, so advisory checks run without waiting for a code change. + - cron: "0 6 * * 1" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + DOTNET_CLI_TELEMETRY_OPTOUT: true + DOTNET_NOLOGO: true + +jobs: + dependency-audit: + name: Dependency audit + runs-on: windows-2025 + timeout-minutes: 15 + + # NU1901-NU1904 are downgraded to warnings in Directory.Build.props so that a + # newly published advisory cannot fail an unrelated pull request. This job is + # the other half of that decision: it fails loudly, on a schedule, so the + # findings are acted on rather than merely visible. + steps: + - name: Check out repository + uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6 + + - name: Set up .NET + uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5 + with: + global-json-file: global.json + + - name: Restore + run: dotnet restore .\GenLauncherGO.sln + + - name: Fail on vulnerable packages + shell: pwsh + run: | + $report = dotnet list .\GenLauncherGO.sln package --vulnerable --include-transitive | Out-String + Write-Host $report + if ($report -match "has the following vulnerable packages") { + throw "Vulnerable packages detected. See the report above." + } + + - name: Report deprecated packages + shell: pwsh + run: | + $report = dotnet list .\GenLauncherGO.sln package --deprecated | Out-String + Write-Host $report + if ($report -match "has the following deprecated packages") { + throw "Deprecated packages detected. See the report above." + } diff --git a/.gitignore b/.gitignore index 4f3be99f..c6e49d9c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,398 +1,35 @@ -## Ignore Visual Studio temporary files, build results, and -## files generated by popular Visual Studio add-ons. -## -## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore +# .NET and Avalonia build/publish output +[Bb]in/ +[Oo]bj/ +/artifacts/ +/publish/ +*.binlog + +# Test results and coverage output +/TestResults/ +/GenLauncherGO.Tests/StrykerOutput/ +*.trx +*.coverage +*.coveragexml -# User-specific files +# Visual Studio +/.vs/ *.rsuser *.suo *.user *.userosscache *.sln.docstates -# User-specific files (MonoDevelop/Xamarin Studio) -*.userprefs - -# Mono auto generated files -mono_crash.* - -# Build results -[Dd]ebug/ -[Dd]ebugPublic/ -[Rr]elease/ -[Rr]eleases/ -x64/ -x86/ -[Ww][Ii][Nn]32/ -[Aa][Rr][Mm]/ -[Aa][Rr][Mm]64/ -bld/ -[Bb]in/ -[Oo]bj/ -[Ll]og/ -[Ll]ogs/ - -# Visual Studio 2015/2017 cache/options directory -.vs/ -# Uncomment if you have tasks that create the project's static files in wwwroot -#wwwroot/ - -# Visual Studio 2017 auto generated files -Generated\ Files/ - -# MSTest test Results -[Tt]est[Rr]esult*/ -[Bb]uild[Ll]og.* - -# NUnit -*.VisualState.xml -TestResult.xml -nunit-*.xml - -# Build Results of an ATL Project -[Dd]ebugPS/ -[Rr]eleasePS/ -dlldata.c - -# Benchmark Results -BenchmarkDotNet.Artifacts/ - -# .NET Core -project.lock.json -project.fragment.lock.json -artifacts/ - -# ASP.NET Scaffolding -ScaffoldingReadMe.txt - -# StyleCop -StyleCopReport.xml - -# Files built by Visual Studio -*_i.c -*_p.c -*_h.h -*.ilk -*.meta -*.obj -*.iobj -*.pch -*.pdb -*.ipdb -*.pgc -*.pgd -*.rsp -*.sbr -*.tlb -*.tli -*.tlh -*.tmp -*.tmp_proj -*_wpftmp.csproj -*.log -*.tlog -*.vspscc -*.vssscc -.builds -*.pidb -*.svclog -*.scc - -# Chutzpah Test files -_Chutzpah* - -# Visual C++ cache files -ipch/ -*.aps -*.ncb -*.opendb -*.opensdf -*.sdf -*.cachefile -*.VC.db -*.VC.VC.opendb - -# Visual Studio profiler -*.psess -*.vsp -*.vspx -*.sap - -# Visual Studio Trace Files -*.e2e - -# TFS 2012 Local Workspace -$tf/ - -# Guidance Automation Toolkit -*.gpState - -# ReSharper is a .NET coding add-in -_ReSharper*/ -*.[Rr]e[Ss]harper +# JetBrains Rider/ReSharper +/.idea/ +_ReSharper.Caches/ +*.sln.iml *.DotSettings.user -# TeamCity is a build add-in -_TeamCity* - -# DotCover is a Code Coverage Tool -*.dotCover - -# AxoCover is a Code Coverage Tool -.axoCover/* -!.axoCover/settings.json - -# Coverlet is a free, cross platform Code Coverage Tool -coverage*.json -coverage*.xml -coverage*.info - -# Visual Studio code coverage results -*.coverage -*.coveragexml - -# NCrunch -_NCrunch_* -.*crunch*.local.xml -nCrunchTemp_* - -# MightyMoose -*.mm.* -AutoTest.Net/ - -# Web workbench (sass) -.sass-cache/ - -# Installshield output folder -[Ee]xpress/ - -# DocProject is a documentation generator add-in -DocProject/buildhelp/ -DocProject/Help/*.HxT -DocProject/Help/*.HxC -DocProject/Help/*.hhc -DocProject/Help/*.hhk -DocProject/Help/*.hhp -DocProject/Help/Html2 -DocProject/Help/html - -# Click-Once directory -publish/ - -# Publish Web Output -*.[Pp]ublish.xml -*.azurePubxml -# Note: Comment the next line if you want to checkin your web deploy settings, -# but database connection strings (with potential passwords) will be unencrypted -*.pubxml -*.publishproj - -# Microsoft Azure Web App publish settings. Comment the next line if you want to -# checkin your Azure Web App publish settings, but sensitive information contained -# in these scripts will be unencrypted -PublishScripts/ - -# NuGet Packages -*.nupkg -# NuGet Symbol Packages -*.snupkg -# The packages folder can be ignored because of Package Restore -**/[Pp]ackages/* -# except build/, which is used as an MSBuild target. -!**/[Pp]ackages/build/ -# Uncomment if necessary however generally it will be regenerated when needed -#!**/[Pp]ackages/repositories.config -# NuGet v3's project.json files produces more ignorable files -*.nuget.props -*.nuget.targets - -# Microsoft Azure Build Output -csx/ -*.build.csdef - -# Microsoft Azure Emulator -ecf/ -rcf/ - -# Windows Store app package directories and files -AppPackages/ -BundleArtifacts/ -Package.StoreAssociation.xml -_pkginfo.txt -*.appx -*.appxbundle -*.appxupload - -# Visual Studio cache files -# files ending in .cache can be ignored -*.[Cc]ache -# but keep track of directories ending in .cache -!?*.[Cc]ache/ - -# Others -ClientBin/ -~$* +# Local editor and user files +*.userprefs *~ -*.dbmdl -*.dbproj.schemaview -*.jfm -*.pfx -*.publishsettings -orleans.codegen.cs - -# Including strong name files can present a security risk -# (https://github.com/github/gitignore/pull/2483#issue-259490424) -#*.snk - -# Since there are multiple workflows, uncomment next line to ignore bower_components -# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) -#bower_components/ - -# RIA/Silverlight projects -Generated_Code/ - -# Backup & report files from converting an old project file -# to a newer Visual Studio version. Backup files are not needed, -# because we have git ;-) -_UpgradeReport_Files/ -Backup*/ -UpgradeLog*.XML -UpgradeLog*.htm -ServiceFabricBackup/ -*.rptproj.bak - -# SQL Server files -*.mdf -*.ldf -*.ndf - -# Business Intelligence projects -*.rdl.data -*.bim.layout -*.bim_*.settings -*.rptproj.rsuser -*- [Bb]ackup.rdl -*- [Bb]ackup ([0-9]).rdl -*- [Bb]ackup ([0-9][0-9]).rdl - -# Microsoft Fakes -FakesAssemblies/ - -# GhostDoc plugin setting file -*.GhostDoc.xml - -# Node.js Tools for Visual Studio -.ntvs_analysis.dat -node_modules/ - -# Visual Studio 6 build log -*.plg - -# Visual Studio 6 workspace options file -*.opt - -# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) -*.vbw - -# Visual Studio 6 auto-generated project file (contains which files were open etc.) -*.vbp - -# Visual Studio 6 workspace and project file (working project files containing files to include in project) -*.dsw -*.dsp - -# Visual Studio 6 technical files -*.ncb -*.aps - -# Visual Studio LightSwitch build output -**/*.HTMLClient/GeneratedArtifacts -**/*.DesktopClient/GeneratedArtifacts -**/*.DesktopClient/ModelManifest.xml -**/*.Server/GeneratedArtifacts -**/*.Server/ModelManifest.xml -_Pvt_Extensions - -# Paket dependency manager -.paket/paket.exe -paket-files/ - -# FAKE - F# Make -.fake/ - -# CodeRush personal settings -.cr/personal - -# Python Tools for Visual Studio (PTVS) -__pycache__/ -*.pyc - -# Cake - Uncomment if you are using it -# tools/** -# !tools/packages.config - -# Tabs Studio -*.tss - -# Telerik's JustMock configuration file -*.jmconfig - -# BizTalk build output -*.btp.cs -*.btm.cs -*.odx.cs -*.xsd.cs - -# OpenCover UI analysis results -OpenCover/ - -# Azure Stream Analytics local run output -ASALocalRun/ - -# MSBuild Binary and Structured Log -*.binlog - -# NVidia Nsight GPU debugger configuration file -*.nvuser - -# MFractors (Xamarin productivity tool) working folder -.mfractor/ - -# Local History for Visual Studio -.localhistory/ - -# Visual Studio History (VSHistory) files -.vshistory/ - -# BeatPulse healthcheck temp database -healthchecksdb - -# Backup folder for Package Reference Convert tool in Visual Studio 2017 -MigrationBackup/ - -# Ionide (cross platform F# VS Code tools) working folder -.ionide/ - -# Fody - auto-generated XML schema -FodyWeavers.xsd - -# VS Code files for those working on multiple tools -.vscode/* -!.vscode/settings.json -!.vscode/tasks.json -!.vscode/launch.json -!.vscode/extensions.json -*.code-workspace - -# Local History for Visual Studio Code -.history/ - -# Windows Installer files from build outputs -*.cab -*.msi -*.msix -*.msm -*.msp +~$* -# JetBrains Rider -*.sln.iml \ No newline at end of file +# Local code indexes +/.codegraph/ diff --git a/.mcp.json b/.mcp.json new file mode 100644 index 00000000..2be80f2c --- /dev/null +++ b/.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "avalonia-docs": { + "type": "http", + "url": "https://docs-mcp.avaloniaui.net/mcp" + } + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..4ca18d70 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,62 @@ +# GenLauncherGO Agent Guidelines + +GenLauncherGO is a small Windows launcher with a native Avalonia UI for Generals and Zero Hour community clients. + +## Workflow + +- Preserve existing behavior unless the user explicitly requests a change. +- Preserve user changes; avoid unrelated cleanup. +- Search for the current owner and callers before adding or replacing shared behavior. +- For Avalonia or WPF-to-Avalonia work, use the repository-configured `avalonia-docs` MCP server and load its expert rules first. +- Use only free Avalonia tooling: the Build MCP documentation, expert-rule, API, mapping, and native-migration tools, the open-source framework, and free legacy tooling. Do not call `migrate_diagnostics` or `recreate-ui`, and skip any Developer Tools setup suggested by `new`. Do not configure the Developer Tools application, DevTools MCP, Avalonia XPF, or another commercial feature unless the owner supplies a license and requests it. +- Verify with `dotnet build GenLauncherGO.sln`, `dotnet format GenLauncherGO.sln --verify-no-changes`, and `dotnet test GenLauncherGO.sln`. Use narrow commands while iterating, then all three across the solution before handoff. +- Trust a diagnostic count only once the build reports no compiler errors. A project that fails to compile reports nothing of its own and hides every diagnostic in the projects downstream of it. +- Apply a bulk fix only at the sites the tool reported, never file-wide, then rebuild the whole solution. A change that compiles where you made it can still break callers in another project, and `dotnet format` rewrites code rather than only whitespace. + +## Project Boundaries + +| Project | Owns | +| --- | --- | +| `GenLauncherGO.Core/` | Domain rules, values, validation, intentional cross-project contracts | +| `GenLauncherGO.Infrastructure/` | Disk, network, archives, processes, hashing, persistence, logging adapters | +| `GenLauncherGO.UI/` | Native Avalonia presentation and the composition root | +| `GenLauncherGO.Tests/` | Observable behavior, safety, compatibility, and invariant tests | + +Read the nearest nested `AGENTS.md` before editing a project. There is intentionally no `src/` folder. + +## Design Gates + +- Optimize for a small launcher: prefer direct calls and concrete `internal sealed` types. +- Core has no external consumers. Do not keep unused public APIs, old names, adapters, or compatibility shims. +- Maintain one authority for content identity, executable names, type mapping, owned paths, settings, and other shared rules. Reuse or move it; never copy it. +- Do not add mediator, CQRS, service-locator, or similar frameworks. +- Do not add speculative extension points or edge cases; require current behavior, an external contract, a reproduced defect, or a safety invariant. +- Keep production code feature-first. Do not add a folder or layer for file count, symmetry, or anticipated growth. +- Fixed arguments, localization keys, or one forwarded call do not justify a type. + +| New artifact | Allowed only when | +| --- | --- | +| Interface | It is an external or side-effect boundary, or has multiple production implementations. Testing convenience alone is insufficient. | +| Request | It validates a stable operation boundary or is genuinely shared; never just bundle arguments for one internal call. | +| Result | Callers branch on named outcomes or need structured failure data; never just mirror returned properties. | +| Factory | It selects implementations or owns meaningful construction or lifetime policy; never merely call `new`. | +| Coordinator | It owns sequencing, state, rollback, or lifecycle; never merely forward calls or group dependencies. | +| Mapper or DTO | It crosses an external or persistence boundary. Map once; do not add an intermediate mirror model. | +| Wrapper | It adds an invariant, ownership, or policy. Otherwise call the existing type directly. | + +## Non-Negotiable Constraints + +- The remote YAML/backend contract is external. Preserve its accepted keys, shapes, defaults, and semantics at the Infrastructure boundary. +- Launch preparation mutates a user's game folder. Preserve ownership, containment, rollback, and recovery defenses. +- Fix style, naming, quality, and formatting violations in the code. Never make a change compile or a test pass by weakening a gate: no new `.editorconfig` severity downgrade or opt-out, no suppression or `#pragma`, no `NoWarn`, no analyzer or warnings-as-errors property change, no skipped or deleted test. If a gate is genuinely wrong, say so and stop. +- A rule earns removal rather than exceptions when it reports false positives, its fixer corrupts source, or its remedy costs more than the defect it names. Scoping a rule to a project is fine; a growing list of per-site carve-outs is not. Record the reason beside the exclusion in `.editorconfig`, and raise removal as its own decision rather than as a way past the violation in front of you. +- Document cross-project contracts and non-obvious side effects, invariants, compatibility constraints, or platform behavior. Do not document obvious implementation details. +- Use `GenLauncherGO` for new names. Do not add a license or release/deployment automation without an explicit owner decision. + +## Completion + +- Remove superseded code in the same change; do not leave parallel paths without a current caller. +- Inspect the final diff for duplicate logic, avoidable types, widened visibility, and tests coupled to implementation details. +- In the handoff, list every new production interface/request/result/factory/coordinator/wrapper and the gate that justified it; say explicitly when none were added. +- Report reused or changed canonical authorities and all verification run. +- Use Conventional Commits when committing: `type(scope): short imperative summary`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..5869473b --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,125 @@ +# Contributing to GenLauncherGO + +Contributions are welcome. Fork the repository, create a branch, make and test your changes, then open a pull request +with a clear description. + +## Development setup + +The repository selects the .NET 10 SDK through `global.json`, starting at version `10.0.300` and allowing later +feature bands. + +Run the Avalonia project from the repository root: + +```powershell +dotnet run --project ./GenLauncherGO.UI/GenLauncherGO.UI.csproj +``` + +For a full launcher UI session, run the executable outside all supported game installations. Startup validation +blocks the launcher when it is placed inside one. + +## Required quality gates + +Run the standard repository checks before submitting a change: + +```powershell +dotnet build GenLauncherGO.sln +dotnet format GenLauncherGO.sln --verify-no-changes +dotnet test GenLauncherGO.sln +``` + +The Windows CI workflow builds Release, verifies formatting, runs the complete test suite with coverage thresholds, +checks each mutation-test area, and verifies the supported single-file publish profile. A separate weekly workflow +audits vulnerable and deprecated dependencies. + +## Symbolic-link safety tests + +Symbolic-link tests are required in CI and fail the workflow if the runner cannot execute them. Local accounts that +cannot create symbolic links report those tests as explicit skips. To enforce the same fail-closed behavior locally, +enable Windows Developer Mode or use an elevated terminal, then run: + +```powershell +$env:GENLAUNCHERGO_REQUIRE_SYMBOLIC_LINK_TESTS = "true" +dotnet test GenLauncherGO.sln +Remove-Item Env:GENLAUNCHERGO_REQUIRE_SYMBOLIC_LINK_TESTS +``` + +## Coverage and mutation testing + +Generate a local coverage report: + +```powershell +dotnet msbuild ./eng/coverage.proj -target:Coverage +``` + +The HTML report is written to `artifacts/coverage/index.html`. Coverage thresholds catch missing execution; they do +not measure assertion quality. + +Mutation-test the domain and infrastructure projects: + +```powershell +dotnet msbuild ./eng/mutation.proj -target:Mutation +``` + +Run one area instead of all seven: + +```powershell +dotnet msbuild ./eng/mutation.proj -target:Mutation -property:MutationConfiguration=infrastructure-launching +``` + +Mutation testing is the gate for whether behavior is meaningfully asserted. It covers every behavior-bearing file in +`GenLauncherGO.Core` and `GenLauncherGO.Infrastructure`, split across `core`, `infrastructure-common`, +`infrastructure-integrity`, `infrastructure-launching`, `infrastructure-mods`, `infrastructure-platform`, and +`infrastructure-updating`. Each area has its own break threshold, so a strongly covered area cannot hide a weaker one. +CI runs these areas as a matrix. + +`GenLauncherGO.UI` is not mutation-tested because Avalonia emits `InitializeComponent` and the `x:Name` backing +fields through a Roslyn source generator. Stryker rebuilds from parsed syntax trees without running generators, so +the generated members are unavailable and the run aborts before testing anything. UI quality is enforced through the +coverage backstop and behavioral tests instead. + +Some mutants survive intentionally. Exception-message text, `ConfigureAwait`, durability flags such as `Flush(true)`, +buffer sizes, and argument guards are either unobservable through behavior tests or excluded by the test guidance. +The Stryker configuration filters what it can, and the thresholds account for the remainder. + +## Publishing + +Publish the supported self-contained, single-file Windows x64 executable: + +```powershell +dotnet publish ./GenLauncherGO.UI/GenLauncherGO.UI.csproj -p:PublishProfile=WinX64SelfContained -o ./publish +``` + +Ordinary Debug and Release builds are framework-dependent and do not select a runtime. The explicit +`WinX64SelfContained` profile produces the supported distributable, with `GenLauncherGO.exe` as the launcher +executable. + +## Architecture + +The solution deliberately uses three production projects, one test project, and one test-only analyzer project, with +no `src` folder: + +| Project | Responsibility | +| --- | --- | +| `GenLauncherGO.Core` | Dependency-light contracts, launcher rules, models, and path identities | +| `GenLauncherGO.Infrastructure` | Disk, network, archive, process, persistence, integrity, and package-provider implementations | +| `GenLauncherGO.UI` | Native Avalonia presentation, user workflows, localization, and the dependency-injection composition root | +| `GenLauncherGO.Tests` | Observable behavior, compatibility, recovery, and file-system safety tests | +| `GenLauncherGO.TestAnalyzers` | Test-only analyzer enforcing the repository's test-method naming convention | + +Dependencies point inward: UI can reference Core and Infrastructure, Infrastructure can reference Core, and Core does +not reference Avalonia or implementation packages. Interfaces represent intentional project or side-effect +boundaries; feature-internal code normally uses sealed concrete types. + +Mutable paths carry their owning root so file operations can reject traversal and reparse-point escapes. + +## Backend compatibility + +GenLauncherGO consumes an external backend tied to [p0ls3r](https://github.com/p0ls3r) and the original GenLauncher +project. This repository does not control that backend, so its legacy remote YAML names and structure are preserved +exactly at the Infrastructure boundary and mapped into the application's internal models. Do not rename or reshape +that manifest contract without a deliberate compatibility plan coordinated with the backend maintainers. + +## Submitting changes + +Keep changes focused, preserve existing behavior unless the change deliberately updates it, and include tests for +observable behavior or safety invariants. In the pull request, explain what changed and list the verification you ran. diff --git a/Claude.md b/Claude.md new file mode 100644 index 00000000..3d251102 --- /dev/null +++ b/Claude.md @@ -0,0 +1 @@ +Read and follow [AGENTS.md](AGENTS.md) and the nearest `AGENTS.md` in any subdirectory before working there. diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 00000000..f8e7d805 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,63 @@ + + + 14.0 + enable + disable + true + + + + + 10.0 + + + true + + + true + $(NoWarn);CS1591 + + + All + All + All + All + All + All + All + All + + + true + + true + + + $(WarningsNotAsErrors);NU1900;NU1901;NU1902;NU1903;NU1904 + + + + + true + + diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 00000000..fe6c73a2 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,25 @@ + + + + <_DisallowedProjectReference Include="@(ProjectReference)" + Condition=" + '$(MSBuildProjectName)' == 'GenLauncherGO.Core' + Or ('$(MSBuildProjectName)' == 'GenLauncherGO.Infrastructure' + And '%(ProjectReference.Filename)' != 'GenLauncherGO.Core') + Or ('$(MSBuildProjectName)' == 'GenLauncherGO.UI' + And '%(ProjectReference.Filename)' != 'GenLauncherGO.Core' + And '%(ProjectReference.Filename)' != 'GenLauncherGO.Infrastructure') + Or ('$(MSBuildProjectName)' == 'GenLauncherGO.Tests' + And '%(ProjectReference.Filename)' != 'GenLauncherGO.Core' + And '%(ProjectReference.Filename)' != 'GenLauncherGO.Infrastructure' + And '%(ProjectReference.Filename)' != 'GenLauncherGO.UI' + And ('%(ProjectReference.Filename)' != 'GenLauncherGO.TestAnalyzers' + Or '%(ProjectReference.OutputItemType)' != 'Analyzer' + Or '%(ProjectReference.ReferenceOutputAssembly)' != 'false'))"/> + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 00000000..12739d1f --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,29 @@ + + + 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..e73adacf --- /dev/null +++ b/GenLauncherGO.Core/AGENTS.md @@ -0,0 +1,12 @@ +# GenLauncherGO.Core Guidance + +- Keep Core dependency-light and side-effect free: no Avalonia or other UI frameworks/resources, Infrastructure, Windows + APIs, disk, network, processes, archives, hashing implementations, remote DTOs, or logging packages. +- Make a type `public` only when another production project consumes it. Public means intra-solution contract, not + external compatibility. +- Model durable identity, configuration, and domain facts as immutable values when practical; mutable workflow and UI + state do not belong here. +- Keep remote YAML names and serialization shapes in Infrastructure; Core receives normalized concepts. +- Pass `CancellationToken` through new asynchronous contracts. +- Keep expected failures explicit only when callers must act on distinct outcomes; otherwise use the simplest normal + .NET mechanism. diff --git a/GenLauncherGO.Core/GenLauncherGO.Core.csproj b/GenLauncherGO.Core/GenLauncherGO.Core.csproj new file mode 100644 index 00000000..555ae434 --- /dev/null +++ b/GenLauncherGO.Core/GenLauncherGO.Core.csproj @@ -0,0 +1,5 @@ + + + net10.0 + + diff --git a/GenLauncherGO.Core/IO/LexicalPath.cs b/GenLauncherGO.Core/IO/LexicalPath.cs new file mode 100644 index 00000000..578fcff7 --- /dev/null +++ b/GenLauncherGO.Core/IO/LexicalPath.cs @@ -0,0 +1,214 @@ +using System; +using System.IO; + +namespace GenLauncherGO.Core.IO; + +/// +/// Provides side-effect-free Windows path normalization, relative-path, and containment operations. +/// +/// +/// These operations are lexical only. Callers that traverse or mutate the filesystem must separately inspect the +/// physical path for reparse points and other unsafe entries. +/// +public static class LexicalPath +{ + /// + /// Returns a fully qualified path without a non-root trailing directory separator. + /// + public static string NormalizeFullPath(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + return Path.TrimEndingDirectorySeparator(Path.GetFullPath(path)); + } + + /// + /// Determines whether two paths identify the same lexical location using Windows case semantics. + /// + /// + /// Missing or malformed paths are not equivalent. Valid paths are fully normalized first, so differences in + /// case, trailing separators, and dot segments do not affect the comparison. + /// + public static bool AreEquivalent(string? left, string? right) + { + if (string.IsNullOrWhiteSpace(left) || string.IsNullOrWhiteSpace(right)) + { + return false; + } + + try + { + return AreNormalizedPathsEquivalent(NormalizeFullPath(left), NormalizeFullPath(right)); + } + catch (Exception exception) when (exception is ArgumentException or IOException or NotSupportedException) + { + return false; + } + } + + /// + /// Normalizes a relative path to slash separators for persisted metadata and comparisons. + /// + public static string NormalizeRelativePath(string path) + { + ArgumentNullException.ThrowIfNull(path); + + return path.Replace('\\', '/').Trim('/'); + } + + /// + /// Gets a normalized slash-separated path from a root to another path. + /// + /// + /// The returned path can identify the root itself or leave it. Call or a + /// containment operation when a caller requires an ownership boundary. + /// + public static string GetRelativePath(string root, string path) + { + return NormalizeRelativePath(Path.GetRelativePath( + NormalizeFullPath(root), + NormalizeFullPath(path))); + } + + /// + /// Resolves a path against a root without inspecting the physical filesystem. + /// + /// + /// Rooted or traversing inputs can resolve outside . Use + /// when containment is required. + /// + public static string ResolvePath(string root, string path) + { + ArgumentNullException.ThrowIfNull(path); + + return NormalizeFullPath(Path.Combine( + NormalizeFullPath(root), + path.Replace('/', Path.DirectorySeparatorChar))); + } + + /// + /// Resolves a path against a root and proves the result stays inside it. + /// + /// The directory the resolved path must remain within. + /// The relative or absolute path to resolve. + /// The message describing what escaped which boundary. + /// + /// This is the single place that pairs resolution with its containment proof. Resolving without the proof + /// is what lets a traversing or rooted input escape an owned directory, so callers that need the boundary + /// enforced should reach for this rather than combining and + /// themselves. + /// + /// Thrown when the resolved path leaves . + public static string ResolveContainedPath(string root, string path, string containmentFailureMessage) + { + string normalizedRoot = NormalizeFullPath(root); + string candidatePath = ResolvePath(normalizedRoot, path); + if (!IsPathInDirectory(candidatePath, normalizedRoot)) + { + throw new InvalidDataException(containmentFailureMessage); + } + + return candidatePath; + } + + /// + /// Determines whether a path is a directory or one of its children using Windows case semantics. + /// + public static bool IsPathInDirectory(string path, string directory) + { + string normalizedDirectory = NormalizeFullPath(directory); + string normalizedPath = NormalizeFullPath(path); + return IsNormalizedPathInDirectory(normalizedPath, normalizedDirectory); + } + + /// + /// Determines whether a path is strictly below a directory using Windows case semantics. + /// + internal static bool IsPathBelowDirectory(string path, string directory) + { + string normalizedDirectory = NormalizeFullPath(directory); + string normalizedPath = NormalizeFullPath(path); + return !AreNormalizedPathsEquivalent(normalizedPath, normalizedDirectory) && + IsNormalizedPathInDirectory(normalizedPath, normalizedDirectory); + } + + /// + /// Determines whether a relative path identifies an entry outside its origin. + /// + public static bool RelativePathLeavesRoot(string relativePath) + { + ArgumentNullException.ThrowIfNull(relativePath); + + string normalizedPath = NormalizeRelativePath(relativePath); + return string.Equals(normalizedPath, "..", StringComparison.Ordinal) || + normalizedPath.StartsWith("../", StringComparison.Ordinal) || + Path.IsPathRooted(relativePath); + } + + /// + /// Normalizes one user-supplied Windows path segment and rejects reserved or traversing names. + /// + public 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('.') || + IsReservedDeviceName(normalizedSegment)) + { + throw new ArgumentException("Path segments must not use reserved file-system names.", paramName); + } + + return normalizedSegment; + } + + 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'; + } + + private static bool AreNormalizedPathsEquivalent(string left, string right) + { + return string.Equals(left, right, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsNormalizedPathInDirectory(string path, string directory) + { + if (AreNormalizedPathsEquivalent(path, directory)) + { + return true; + } + + string directoryPrefix = Path.EndsInDirectorySeparator(directory) + ? directory + : directory + Path.DirectorySeparatorChar; + return path.StartsWith(directoryPrefix, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/GenLauncherGO.Core/Integrity/Models/ContentIntegrityIssue.cs b/GenLauncherGO.Core/Integrity/Models/ContentIntegrityIssue.cs new file mode 100644 index 00000000..d4a57d7b --- /dev/null +++ b/GenLauncherGO.Core/Integrity/Models/ContentIntegrityIssue.cs @@ -0,0 +1,11 @@ +namespace GenLauncherGO.Core.Integrity.Models; + +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..5314d7b9 --- /dev/null +++ b/GenLauncherGO.Core/Integrity/Models/ContentIntegrityReport.cs @@ -0,0 +1,25 @@ +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 +{ + public ContentIntegrityReport(IReadOnlyList issues) + { + ArgumentNullException.ThrowIfNull(issues); + Issues = Array.AsReadOnly(issues.ToArray()); + } + + public IReadOnlyList Issues { get; } + + public bool HasIssues => Issues.Count > 0; + + public bool HasUnknownLegacyIssues => Issues.Any(issue => issue.Action == IntegrityIssueAction.TrustAsManual); + + 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..5a668a6b --- /dev/null +++ b/GenLauncherGO.Core/Integrity/Models/ContentIntegrityTarget.cs @@ -0,0 +1,50 @@ +using System; +using System.Collections.Frozen; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.IO; + +namespace GenLauncherGO.Core.Integrity.Models; + +/// +/// Describes one launcher-owned directory that must be verified. +/// +public sealed record ContentIntegrityTarget +{ + 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 + .Select(LexicalPath.NormalizeRelativePath) + .ToFrozenSet(StringComparer.OrdinalIgnoreCase); + } + + /// + /// Gets the stable identifier used for snapshot persistence. + /// + public string Id { get; } + + public string DisplayName { get; } + + public string RootDirectory { get; } + + public ContentSourceKind SourceKind { get; init; } + + /// + /// Gets known owned paths that belong to inactive content and must be preserved without verification. + /// + public IReadOnlySet IgnoredRelativePaths { get; } +} diff --git a/GenLauncherGO.Core/Integrity/Models/ContentSourceKind.cs b/GenLauncherGO.Core/Integrity/Models/ContentSourceKind.cs new file mode 100644 index 00000000..70e5aa1c --- /dev/null +++ b/GenLauncherGO.Core/Integrity/Models/ContentSourceKind.cs @@ -0,0 +1,41 @@ +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 +} + +/// +/// Defines shared classifications for launcher content sources. +/// +public static class ContentSourceKindExtensions +{ + /// + /// Determines whether the launcher can restore the content from a managed remote source. + /// + public static bool IsManagedRemote(this ContentSourceKind sourceKind) + { + return sourceKind is ContentSourceKind.ManagedS3 or ContentSourceKind.ManagedSingleFile; + } +} diff --git a/GenLauncherGO.Core/Integrity/Models/IntegrityIssueAction.cs b/GenLauncherGO.Core/Integrity/Models/IntegrityIssueAction.cs new file mode 100644 index 00000000..75180ae7 --- /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..4ed8ceb3 --- /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/IGameExecutableDiscoveryService.cs b/GenLauncherGO.Core/Launching/Contracts/IGameExecutableDiscoveryService.cs new file mode 100644 index 00000000..eef5f28e --- /dev/null +++ b/GenLauncherGO.Core/Launching/Contracts/IGameExecutableDiscoveryService.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Launching.Models; + +namespace GenLauncherGO.Core.Launching.Contracts; + +/// +/// Discovers game and World Builder executables available to the current launcher session. +/// +public interface IGameExecutableDiscoveryService +{ + /// + /// Gets the built-in game client executables for the active game installation. + /// + IReadOnlyList GetGameClients(); + + /// + /// Gets the built-in World Builder executables for the active game installation. + /// + IReadOnlyList GetWorldBuilders(); + + /// + /// Determines whether a root-level executable file name is currently available and safe to launch. + /// + 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..dd1d1fa8 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Contracts/IGameProcessLaunchOperation.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading.Tasks; + +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 for the currently running tracked process. + /// + string CurrentExecutableName { get; } + + /// + /// Gets the task that completes when every tracked process in the launched process family has exited. + /// + Task Completion { get; } + + /// + /// Occurs when changes. + /// + event EventHandler? CurrentExecutableNameChanged; + + /// + /// 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..f1bd24b8 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Contracts/IGameProcessLauncher.cs @@ -0,0 +1,18 @@ +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Models; + +namespace GenLauncherGO.Core.Launching.Contracts; + +/// +/// Launches supported game and tool processes for a prepared game directory. +/// +public interface IGameProcessLauncher +{ + /// + /// Starts the requested game or tool process and returns an operation that tracks its process family. + /// + Task StartAsync( + 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..b5bff125 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Contracts/ILaunchContentIntegrityResolutionService.cs @@ -0,0 +1,55 @@ +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. + /// + Task VerifyAsync( + LaunchContentIntegrityTargetRequest request, + CancellationToken cancellationToken); + + /// + /// Captures initial snapshots for matching managed remote caches and reports whether any target was initialized. + /// + Task InitializeUntrackedManagedCachesAsync( + LaunchContentIntegrityResolutionRequest request, + CancellationToken cancellationToken); + + /// + /// Applies confirmed launch-integrity resolutions, including snapshots, cleanup, package repair, and cache refresh. + /// + Task ResolveAsync( + LaunchContentIntegrityResolutionRequest request, + IProgress? progress, + CancellationToken cancellationToken); + + /// + /// Marks a manually imported version as manual content and captures its initial package and cache snapshots. + /// + Task RegisterManualImportAsync( + LaunchContentIntegrityVersionRequest request, + CancellationToken cancellationToken); + + /// + /// Captures initial snapshots for a newly installed managed remote version. + /// + Task CaptureManagedInstallSnapshotAsync( + LaunchContentIntegrityVersionRequest request, + CancellationToken cancellationToken); + + /// + /// Captures a trusted snapshot for a manually managed cached image target. + /// + Task CaptureManualImageSnapshotAsync( + LaunchContentIntegrityVersionRequest request, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Launching/Contracts/ILaunchPreparationService.cs b/GenLauncherGO.Core/Launching/Contracts/ILaunchPreparationService.cs new file mode 100644 index 00000000..b9b3ff89 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Contracts/ILaunchPreparationService.cs @@ -0,0 +1,35 @@ +using System.Threading; +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. + /// + /// when preparation completed successfully. + bool Prepare( + LaunchPreparationRequest request, + CancellationToken cancellationToken); + + /// + /// Cleans launch-time game-directory state after a launched process exits. + /// + /// when cleanup completed successfully. + bool Cleanup( + LauncherPaths paths, + CancellationToken cancellationToken); + + /// + /// Recovers interrupted launch-time game-directory state during launcher startup. + /// + /// when recovery completed successfully. + bool Recover( + LauncherPaths paths, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Launching/LauncherGameArgumentService.cs b/GenLauncherGO.Core/Launching/LauncherGameArgumentService.cs new file mode 100644 index 00000000..c3af2e05 --- /dev/null +++ b/GenLauncherGO.Core/Launching/LauncherGameArgumentService.cs @@ -0,0 +1,151 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GenLauncherGO.Core.Launching; + +/// +/// Updates game executable command-line arguments controlled by launcher settings. +/// +public static class LauncherGameArgumentService +{ + /// + /// The argument that starts the game in windowed mode. + /// + public const string WindowedArgument = "-win"; + + /// + /// The argument that skips the normal game startup sequence. + /// + public const string QuickStartArgument = "-quickstart"; + + /// + /// The Generals Online launcher override that prevents it from adding windowed mode. + /// + public const string GeneralsOnlineFullscreenArgument = "-fullscreen"; + + /// + /// The Generals Online game argument that disables its community data patch for the current launch. + /// + public const string GeneralsOnlineDisableCommunityDataPatchArgument = "-disableCommunityDataPatch"; + + /// + /// Adds or removes a command-line argument. + /// + public static string SetArgumentEnabled(string? arguments, string argument, bool enabled) + { + return enabled + ? AddArgument(arguments, argument) + : RemoveArgument(arguments, argument); + } + + /// + /// Determines whether the argument string contains a standalone command-line argument. + /// + public static bool ContainsArgument(string? arguments, string argument) + { + EnsureArgument(argument); + + return EnumerateTokens(arguments) + .Any(token => string.Equals(token.Value, argument, StringComparison.OrdinalIgnoreCase)); + } + + private static string AddArgument(string? arguments, string argument) + { + if (ContainsArgument(arguments, argument)) + { + return arguments ?? string.Empty; + } + + if (string.IsNullOrWhiteSpace(arguments)) + { + return argument; + } + + return $"{arguments.Trim()} {argument}"; + } + + private static string RemoveArgument(string? arguments, string argument) + { + EnsureArgument(argument); + + if (string.IsNullOrWhiteSpace(arguments)) + { + return string.Empty; + } + + return string.Join( + ' ', + EnumerateTokens(arguments) + .Where(token => !string.Equals(token.Value, argument, StringComparison.OrdinalIgnoreCase)) + .Select(token => token.Raw)); + } + + private static void EnsureArgument(string argument) + { + ArgumentException.ThrowIfNullOrWhiteSpace(argument); + } + + /// + /// Enumerates whitespace-delimited command-line tokens while preserving quoted token text. + /// + private static IEnumerable<(string Raw, string Value)> EnumerateTokens(string? arguments) + { + if (string.IsNullOrWhiteSpace(arguments)) + { + yield break; + } + + int index = 0; + while (index < arguments.Length) + { + while (index < arguments.Length && char.IsWhiteSpace(arguments[index])) + { + index++; + } + + if (index >= arguments.Length) + { + yield break; + } + + int start = index; + bool isQuoted = arguments[index] == '"'; + bool inQuotes = isQuoted; + if (isQuoted) + { + index++; + } + + while (index < arguments.Length) + { + char current = arguments[index]; + if (current == '"') + { + inQuotes = !inQuotes; + index++; + continue; + } + + if (!inQuotes && char.IsWhiteSpace(current)) + { + break; + } + + index++; + } + + int end = index; + string raw = arguments[start..end]; + string value = GetComparableTokenValue(raw, isQuoted); + yield return (raw, value); + } + } + + private static string GetComparableTokenValue(string raw, bool isQuoted) + { + return isQuoted && raw.Length >= 2 && raw[^1] == '"' + ? raw[1..^1] + : raw; + } +} diff --git a/GenLauncherGO.Core/Launching/Models/BuiltInExecutable.cs b/GenLauncherGO.Core/Launching/Models/BuiltInExecutable.cs new file mode 100644 index 00000000..8b77f7b6 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/BuiltInExecutable.cs @@ -0,0 +1,45 @@ +using System; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes one built-in game or World Builder executable discovered in the active game directory. +/// +public sealed class BuiltInExecutable +{ + public BuiltInExecutable(string executableName, bool isAvailable) + { + ExecutableName = LauncherFileSystemLayout.NormalizeExecutableFileName(executableName); + Kind = ResolveKind(ExecutableName); + IsAvailable = isAvailable; + } + + public string ExecutableName { get; } + + public BuiltInExecutableKind Kind { get; } + + public bool IsAvailable { get; } + + private static BuiltInExecutableKind ResolveKind(string executableName) + { + if (string.Equals( + executableName, + LauncherFileSystemLayout.GeneralsOnlineExecutableFileName, + StringComparison.OrdinalIgnoreCase)) + { + return BuiltInExecutableKind.GeneralsOnline; + } + + return string.Equals( + executableName, + LauncherFileSystemLayout.RetailGameExecutableFileName, + StringComparison.OrdinalIgnoreCase) || + string.Equals( + executableName, + LauncherFileSystemLayout.RetailWorldBuilderExecutableFileName, + StringComparison.OrdinalIgnoreCase) + ? BuiltInExecutableKind.Retail + : BuiltInExecutableKind.Community; + } +} diff --git a/GenLauncherGO.Core/Launching/Models/BuiltInExecutableKind.cs b/GenLauncherGO.Core/Launching/Models/BuiltInExecutableKind.cs new file mode 100644 index 00000000..1f0c104d --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/BuiltInExecutableKind.cs @@ -0,0 +1,13 @@ +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Identifies the launcher-specific behavior of a built-in executable. +/// +public enum BuiltInExecutableKind +{ + Community, + + Retail, + + GeneralsOnline +} diff --git a/GenLauncherGO.Core/Launching/Models/GameLaunchRequest.cs b/GenLauncherGO.Core/Launching/Models/GameLaunchRequest.cs new file mode 100644 index 00000000..a2305f7a --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/GameLaunchRequest.cs @@ -0,0 +1,57 @@ +using System; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Describes a game or World Builder process launch request. +/// +public sealed record GameLaunchRequest +{ + private GameLaunchRequest( + GameLaunchTargetKind targetKind, + string gameDirectory, + string executableName, + string? arguments) + { + ArgumentException.ThrowIfNullOrWhiteSpace(gameDirectory); + + TargetKind = targetKind; + GameDirectory = LexicalPath.NormalizeFullPath(gameDirectory); + ExecutableName = LauncherFileSystemLayout.NormalizeExecutableFileName(executableName); + Arguments = arguments ?? string.Empty; + } + + public GameLaunchTargetKind TargetKind { get; } + + public string GameDirectory { get; } + + public string ExecutableName { get; } + + public string Arguments { get; } + + public static GameLaunchRequest ForGameClient( + string gameDirectory, + string executableName, + string? arguments) + { + return new GameLaunchRequest( + GameLaunchTargetKind.GameClient, + gameDirectory, + executableName, + arguments); + } + + public static GameLaunchRequest ForWorldBuilder( + string gameDirectory, + string executableName, + string? arguments) + { + return new GameLaunchRequest( + GameLaunchTargetKind.WorldBuilder, + gameDirectory, + executableName, + arguments); + } +} diff --git a/GenLauncherGO.Core/Launching/Models/GameLaunchTargetKind.cs b/GenLauncherGO.Core/Launching/Models/GameLaunchTargetKind.cs new file mode 100644 index 00000000..5474695b --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/GameLaunchTargetKind.cs @@ -0,0 +1,8 @@ +namespace GenLauncherGO.Core.Launching.Models; + +public enum GameLaunchTargetKind +{ + GameClient, + + WorldBuilder +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionProgress.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionProgress.cs new file mode 100644 index 00000000..b9fcb076 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionProgress.cs @@ -0,0 +1,40 @@ +using System; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Reports package progress or completion for one launch-integrity resolution target. +/// +public sealed record LaunchContentIntegrityResolutionProgress +{ + private LaunchContentIntegrityResolutionProgress( + string targetId, + PackageUpdateProgress? packageProgress) + { + ArgumentException.ThrowIfNullOrWhiteSpace(targetId); + + TargetId = targetId; + PackageProgress = packageProgress; + } + + public string TargetId { get; } + + public PackageUpdateProgress? PackageProgress { get; } + + public bool Completed => PackageProgress is null; + + public static LaunchContentIntegrityResolutionProgress Package( + string targetId, + PackageUpdateProgress packageProgress) + { + ArgumentNullException.ThrowIfNull(packageProgress); + + return new LaunchContentIntegrityResolutionProgress(targetId, packageProgress); + } + + public static LaunchContentIntegrityResolutionProgress Complete(string targetId) + { + return new LaunchContentIntegrityResolutionProgress(targetId, null); + } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionRequest.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionRequest.cs new file mode 100644 index 00000000..5fdbe802 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityResolutionRequest.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +public sealed record LaunchContentIntegrityResolutionRequest +{ + public LaunchContentIntegrityResolutionRequest( + LauncherPaths paths, + ContentIntegrityReport report, + IReadOnlyList targetContexts) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(report); + ArgumentNullException.ThrowIfNull(targetContexts); + + Paths = paths; + Report = report; + TargetContexts = targetContexts.ToArray(); + } + + public LauncherPaths Paths { get; } + + public ContentIntegrityReport Report { get; } + + public IReadOnlyList TargetContexts { get; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetContext.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetContext.cs new file mode 100644 index 00000000..b492b79c --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetContext.cs @@ -0,0 +1,27 @@ +using System; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Launching.Models; + +public sealed record LaunchContentIntegrityTargetContext +{ + public LaunchContentIntegrityTargetContext( + ContentIntegrityTarget target, + LauncherContentVersion version, + bool isCache) + { + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(version); + + Target = target; + Version = version; + IsCache = isCache; + } + + public ContentIntegrityTarget Target { get; } + + public LauncherContentVersion Version { get; } + + public bool IsCache { get; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetRequest.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetRequest.cs new file mode 100644 index 00000000..d5c30449 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityTargetRequest.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +public sealed record LaunchContentIntegrityTargetRequest +{ + public LaunchContentIntegrityTargetRequest( + LauncherPaths paths, + IReadOnlyList activeVersions, + IReadOnlyList allVersions, + string cacheDisplayNameSuffix) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(activeVersions); + ArgumentNullException.ThrowIfNull(allVersions); + ArgumentException.ThrowIfNullOrWhiteSpace(cacheDisplayNameSuffix); + + Paths = paths; + ActiveVersions = activeVersions.ToArray(); + AllVersions = allVersions.ToArray(); + CacheDisplayNameSuffix = cacheDisplayNameSuffix; + } + + public LauncherPaths Paths { get; } + + public IReadOnlyList ActiveVersions { get; } + + public IReadOnlyList AllVersions { get; } + + public string CacheDisplayNameSuffix { get; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVerificationResult.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVerificationResult.cs new file mode 100644 index 00000000..0783eeb6 --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVerificationResult.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Core.Launching.Models; + +public sealed record LaunchContentIntegrityVerificationResult +{ + public LaunchContentIntegrityVerificationResult( + ContentIntegrityReport report, + IReadOnlyList targetContexts) + { + ArgumentNullException.ThrowIfNull(report); + ArgumentNullException.ThrowIfNull(targetContexts); + + Report = report; + TargetContexts = targetContexts.ToArray(); + } + + public ContentIntegrityReport Report { get; } + + public IReadOnlyList TargetContexts { get; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVersionRequest.cs b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVersionRequest.cs new file mode 100644 index 00000000..9fb7c39a --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchContentIntegrityVersionRequest.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +public sealed record LaunchContentIntegrityVersionRequest +{ + public LaunchContentIntegrityVersionRequest( + LauncherPaths paths, + LauncherContentVersion version, + IReadOnlyList allVersions, + string cacheDisplayNameSuffix) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(version); + ArgumentNullException.ThrowIfNull(allVersions); + ArgumentException.ThrowIfNullOrWhiteSpace(cacheDisplayNameSuffix); + + Paths = paths; + Version = version; + AllVersions = allVersions.ToArray(); + CacheDisplayNameSuffix = cacheDisplayNameSuffix; + } + + public LauncherPaths Paths { get; } + + public LauncherContentVersion Version { get; } + + public IReadOnlyList AllVersions { get; } + + public string CacheDisplayNameSuffix { get; } +} diff --git a/GenLauncherGO.Core/Launching/Models/LaunchPreparationRequest.cs b/GenLauncherGO.Core/Launching/Models/LaunchPreparationRequest.cs new file mode 100644 index 00000000..8e9cad4f --- /dev/null +++ b/GenLauncherGO.Core/Launching/Models/LaunchPreparationRequest.cs @@ -0,0 +1,32 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Launching.Models; + +/// +/// Carries selected installed content and the base-script deployment policy from UI to Infrastructure. +/// +public sealed record LaunchPreparationRequest +{ + public LaunchPreparationRequest( + LauncherPaths paths, + IReadOnlyList versions, + bool disableBaseGameScriptFiles) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(versions); + + Paths = paths; + Versions = versions.ToArray(); + DisableBaseGameScriptFiles = disableBaseGameScriptFiles; + } + + public LauncherPaths Paths { get; } + + public IReadOnlyList Versions { get; } + + public bool DisableBaseGameScriptFiles { get; } +} diff --git a/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalog.cs b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalog.cs new file mode 100644 index 00000000..3e3cce5d --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/ILauncherContentCatalog.cs @@ -0,0 +1,90 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Exceptions; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Mods.Contracts; + +/// +/// Owns the active launcher content aggregate and coordinates its loading, mutation, and persistence. +/// +public interface ILauncherContentCatalog +{ + /// + /// Gets the active in-memory launcher content aggregate. + /// + LauncherData Data { get; } + + /// + /// Gets the active advertising content, or when none is available. + /// + LauncherContentVersion? Advertising { get; } + + /// + /// Gets modification names advertised by the remote repository, or when unavailable. + /// + IReadOnlyList? RepositoryModificationNames { get; } + + /// + /// Initializes the catalog from local state and, when available, the remote repository. + /// + Task InitDataAsync( + LauncherContentCatalogInitializationRequest request, + CancellationToken cancellationToken); + + /// + /// Reads add-ons and patches that belong to the original game. + /// + Task ReadOriginalGameAddonsAndPatchesAsync(CancellationToken cancellationToken); + + /// + /// Reads one repository modification's normalized metadata without adding it to the active catalog. + /// + Task GetRepositoryModificationMetadataAsync( + string name, + CancellationToken cancellationToken); + + /// + /// Downloads one repository modification, caches its images, and adds it to the active catalog. + /// + Task AddRepositoryModificationAsync( + string name, + CancellationToken cancellationToken); + + /// + /// Reads remote patches and add-ons for a modification. + /// + Task ReadPatchesAndAddonsForModAsync( + LauncherContentKey modificationKey, + CancellationToken cancellationToken); + + /// + /// Deletes one installed version and reconciles local state while retaining available catalog metadata. + /// + void UninstallVersion(LauncherContentKey contentKey); + + /// + /// Deletes one installed version, discards that version's catalog metadata, and reconciles local state. + /// + void DiscardVersion(LauncherContentKey contentKey); + + /// + /// Deletes all installed files for a content card, discards the whole card, and reconciles local state. + /// + void DiscardContent(LauncherContentKey contentKey); + + /// + /// Refreshes the catalog from locally installed content and removes stale local-only cards. + /// + void UpdateLocalModificationsData(); + + /// + /// Saves the current catalog selection and installed state. + /// + /// + /// Thrown when the current state cannot be persisted. The in-memory catalog remains authoritative so callers can + /// retry without rolling back completed file-system work. + /// + void SaveLauncherData(); +} diff --git a/GenLauncherGO.Core/Mods/Contracts/IManualModificationImporter.cs b/GenLauncherGO.Core/Mods/Contracts/IManualModificationImporter.cs new file mode 100644 index 00000000..20df2429 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/IManualModificationImporter.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; +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 source files into the explicitly owned destination directory. + /// + void Import( + IReadOnlyList sourceFilePaths, + OwnedContentPath destinationPath, + 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..ffc8f7c1 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/IModificationImageFileService.cs @@ -0,0 +1,49 @@ +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Mods.Contracts; + +/// +/// Provides launcher modification image cache file operations. +/// +/// +/// The modification type participates in cache identity because advertising names retain legacy filesystem +/// normalization. +/// +public interface IModificationImageFileService +{ + /// + /// Finds an existing cached modification image with any extension. + /// + string? FindExistingImageFilePath( + ModificationType modificationType, + string modificationName, + string imageBaseName); + + /// + /// Counts cached image files for a modification. + /// + int CountImageFiles(ModificationType modificationType, string modificationName); + + /// + /// Determines whether a path inside the active launcher-owned image cache points to an existing file. + /// + bool ImageExists(string? imageFilePath); + + /// + /// Removes cached images for a logical image identity and reports whether no matching image remains. + /// + /// The implementation resolves the logical identity inside the active image cache ownership boundary. + bool TryDeleteImage( + ModificationType modificationType, + string modificationName, + string imageBaseName); + + /// + /// Replaces cached images for a modification image base name with a selected source image. + /// + Task ReplaceImageAsync( + ModificationImageReplacementRequest request, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Mods/Contracts/IModificationThemeCache.cs b/GenLauncherGO.Core/Mods/Contracts/IModificationThemeCache.cs new file mode 100644 index 00000000..2747fdff --- /dev/null +++ b/GenLauncherGO.Core/Mods/Contracts/IModificationThemeCache.cs @@ -0,0 +1,24 @@ +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Mods.Contracts; + +/// +/// Remembers the palette a modification published, so a themed launcher survives an offline restart. +/// +/// +/// This is a cache of remote manifest data, never an authority: whenever the catalog reaches the backend the +/// published palette wins, and a missing or unreadable entry simply means the launcher wears the active game's +/// palette instead. Entries live beside the modification's cached artwork and are removed with it. +/// +public interface IModificationThemeCache +{ + /// + /// Stores the palette published for one content version, replacing any previously cached entry. + /// + void Save(LauncherContentKey contentKey, LauncherContentTheme theme); + + /// + /// Loads the palette cached for one content version, or when none is available. + /// + LauncherContentTheme? Load(LauncherContentKey contentKey); +} diff --git a/GenLauncherGO.Core/Mods/Exceptions/LauncherContentPersistenceException.cs b/GenLauncherGO.Core/Mods/Exceptions/LauncherContentPersistenceException.cs new file mode 100644 index 00000000..913e1606 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Exceptions/LauncherContentPersistenceException.cs @@ -0,0 +1,12 @@ +using System; + +namespace GenLauncherGO.Core.Mods.Exceptions; + +public sealed class LauncherContentPersistenceException : Exception +{ + public LauncherContentPersistenceException(Exception innerException) + : base("Launcher content state could not be persisted.", innerException) + { + ArgumentNullException.ThrowIfNull(innerException); + } +} diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContent.cs b/GenLauncherGO.Core/Mods/Models/LauncherContent.cs new file mode 100644 index 00000000..0852d862 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContent.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Owns one launcher content card, its versions, and the single policy for merging catalog and local state. +/// +public sealed class LauncherContent +{ + private readonly List _versions = []; + + public LauncherContent(LauncherContentVersion version) + { + ArgumentNullException.ThrowIfNull(version); + + ContentKey = version.ContentKey.WithoutVersion(); + Versions = _versions.AsReadOnly(); + AddOrMergeVersion(version); + } + + public LauncherContentKey ContentKey { get; } + + public IReadOnlyList Versions { get; } + + /// + /// Gets the latest known version, which is the card's presentation metadata authority. + /// + public LauncherContentVersion LatestVersion => + _versions.OrderBy(version => version).Last(); + + /// + /// Gets the latest installed version, or when no version is installed. + /// + public LauncherContentVersion? LatestInstalledVersion => + _versions + .Where(version => version.Installation.Installed) + .OrderBy(version => version) + .LastOrDefault(); + + public ModificationType ModificationType => ContentKey.ContentType; + + public string Name => ContentKey.Name; + + public bool Installed => _versions.Any(version => version.Installation.Installed); + + public bool IsSelected { get; set; } + + public int NumberInList { get; set; } + + /// + /// Gets the persisted installed selection, falling back to the earliest installed or known version. + /// + /// + /// The fallback preserves the launcher's legacy behavior when saved selection state is missing. + /// + public LauncherContentVersion? GetSelectedVersion() + { + return _versions.FirstOrDefault(version => + version.Installation.Installed && + version.Installation.IsSelected) ?? + _versions + .Where(version => version.Installation.Installed) + .OrderBy(version => version) + .FirstOrDefault() ?? + _versions + .OrderBy(version => version) + .FirstOrDefault(); + } + + /// + /// Adds a new version or merges metadata and local installation state into the matching version. + /// + internal void AddOrMergeVersion(LauncherContentVersion version) + { + ArgumentNullException.ThrowIfNull(version); + + if (version.ContentKey.WithoutVersion() != ContentKey) + { + throw new ArgumentException( + "A launcher content version cannot be merged into a different content card.", + nameof(version)); + } + + int versionIndex = _versions.FindIndex(candidate => + candidate.ContentKey == version.ContentKey); + if (versionIndex < 0) + { + _versions.Add(version); + } + else + { + _versions[versionIndex] = MergeVersion(_versions[versionIndex], version); + } + + IsSelected |= version.Installation.IsSelected; + } + + /// + /// Removes the matching version from this content card. + /// + internal void RemoveVersion(LauncherContentKey contentKey) + { + int versionIndex = _versions.FindIndex(candidate => + candidate.ContentKey == contentKey); + if (versionIndex >= 0) + { + _versions.RemoveAt(versionIndex); + } + } + + /// + /// Applies the legacy-compatible catalog merge precedence once while retaining one shared local-state object. + /// + private static LauncherContentVersion MergeVersion( + LauncherContentVersion existing, + LauncherContentVersion incoming) + { + LauncherContentInstallation installation = existing.Installation; + installation.Installed |= incoming.Installation.Installed; + installation.IsSelected |= incoming.Installation.IsSelected; + if (installation.ContentSourceKind == ContentSourceKind.UnknownLegacy && + incoming.Installation.ContentSourceKind != ContentSourceKind.UnknownLegacy) + { + installation.ContentSourceKind = incoming.Installation.ContentSourceKind; + } + + var merged = new LauncherContentVersion(installation) + { + ModificationType = existing.ModificationType, + Name = existing.Name, + Version = existing.Version, + SimpleDownloadLink = FirstNonEmpty(existing.SimpleDownloadLink, incoming.SimpleDownloadLink), + UIImageSourceLink = FirstNonEmpty(existing.UIImageSourceLink, incoming.UIImageSourceLink), + DiscordLink = FirstNonEmpty(existing.DiscordLink, incoming.DiscordLink), + ModDBLink = FirstNonEmpty(existing.ModDBLink, incoming.ModDBLink), + NewsLink = FirstNonEmpty(existing.NewsLink, incoming.NewsLink), + ParentContentName = existing.ParentContentName, + S3HostLink = FirstNonEmpty(existing.S3HostLink, incoming.S3HostLink), + S3BucketName = FirstNonEmpty(existing.S3BucketName, incoming.S3BucketName), + S3FolderName = FirstNonEmpty(existing.S3FolderName, incoming.S3FolderName), + S3HostPublicKey = FirstNonEmpty(existing.S3HostPublicKey, incoming.S3HostPublicKey), + S3HostSecretKey = FirstNonEmpty(existing.S3HostSecretKey, incoming.S3HostSecretKey), + NetworkInfo = FirstNonEmpty(existing.NetworkInfo, incoming.NetworkInfo), + Deprecated = incoming.Deprecated, + SupportLink = FirstNonEmpty(existing.SupportLink, incoming.SupportLink), + Theme = incoming.Theme ?? existing.Theme + }; + + installation.ContentSourceKind = merged.EffectiveContentSourceKind; + return merged; + } + + private static string FirstNonEmpty(string existing, string incoming) + { + return !string.IsNullOrEmpty(existing) ? existing : incoming; + } +} diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentCatalogInitializationRequest.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentCatalogInitializationRequest.cs new file mode 100644 index 00000000..7e92a65e --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentCatalogInitializationRequest.cs @@ -0,0 +1,11 @@ +using System; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Initializes one game catalog; a missing remote manifest URI selects local-only mode. +/// +public sealed record LauncherContentCatalogInitializationRequest( + Uri? RemoteManifestUri, + LauncherPaths Paths); diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentFileTypes.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentFileTypes.cs new file mode 100644 index 00000000..62a631b8 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentFileTypes.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.IO; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Names the file formats the launcher accepts as content. +/// +/// +/// Extraction, the artwork cache, package download naming, and the pickers a user chooses files with all branch +/// on these sets, and they agree only because they read them from here. A format offered by a picker but absent +/// from the extractor hands the user a file the launcher then refuses to unpack, and the mismatch is invisible +/// until someone tries it. +/// +public static class LauncherContentFileTypes +{ + /// + /// The extension a game package uses while it is downloaded and deployed. + /// + public const string BigExtension = ".big"; + + /// + /// The extension a game package is stored under once installed, keeping it inert for the game. + /// + public const string GibExtension = ".gib"; + + /// + /// The extension artwork falls back to when a remote image URL declares none the launcher accepts. + /// + public const string DefaultImageExtension = ".png"; + + /// + /// Gets the archive formats the launcher unpacks after a download or a manual import. + /// + public static IReadOnlyList ArchiveExtensions { get; } = + Array.AsReadOnly([".zip", ".rar", ".7z"]); + + /// + /// Gets the game package files a user can import directly, without extraction. + /// + public static IReadOnlyList GamePackageExtensions { get; } = + Array.AsReadOnly([BigExtension, GibExtension]); + + /// + /// Gets the image formats accepted for modification artwork. + /// + public static IReadOnlyList ImageExtensions { get; } = + Array.AsReadOnly([DefaultImageExtension, ".jpg", ".jpeg"]); + + /// + /// Determines whether a path names an archive the launcher unpacks rather than copies. + /// + public static bool IsArchive(string filePath) + { + return HasExtension(ArchiveExtensions, Path.GetExtension(filePath)); + } + + /// + /// Determines whether an extension names an artwork format the launcher caches as published. + /// + public static bool IsImage(string extension) + { + return HasExtension(ImageExtensions, extension); + } + + private static bool HasExtension(IReadOnlyList extensions, string extension) + { + foreach (string candidate in extensions) + { + if (string.Equals(candidate, extension, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } +} diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentInstallation.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentInstallation.cs new file mode 100644 index 00000000..14870543 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentInstallation.cs @@ -0,0 +1,38 @@ +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Stores mutable local state for one launcher content version. +/// +/// +/// Remote metadata is immutable on . Installation discovery, selection, and +/// integrity trust decisions mutate this separate state and are the only values persisted locally for a version. +/// +public sealed class LauncherContentInstallation +{ + public bool Installed { get; set; } + + public bool IsSelected { get; set; } + + /// + /// Gets or sets whether a download for this version stopped with its partial content deliberately kept. + /// + /// + /// Set when the launcher closes while a download is in flight, so the next session offers to resume instead of + /// starting over. An explicit cancel clears the partial content and leaves this false. + /// + public bool DownloadSuspended { get; set; } + + /// + /// Gets or sets the progress the suspended download had reached, as a percentage. + /// + /// + /// This restores the progress bar's position for the user. It is a display value only: the byte offset a + /// resumed transfer actually continues from is derived from the partial content on disk, so a stale value here + /// can never cause the wrong range to be requested. + /// + public double SuspendedProgressPercentage { get; set; } + + public ContentSourceKind ContentSourceKind { get; set; } = ContentSourceKind.UnknownLegacy; +} diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentKey.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentKey.cs new file mode 100644 index 00000000..7f50b2ec --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentKey.cs @@ -0,0 +1,124 @@ +using System; +using System.Diagnostics.CodeAnalysis; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Identifies launcher content by type, parent identity, name, and version. +/// +/// +/// Identity text retains its supplied representation and is compared using ordinal, case-insensitive semantics. +/// Missing text is equivalent to an empty string. The original-game key is a stable nonlocalized relationship +/// identity and must not be used as user-visible display text. +/// +public readonly struct LauncherContentKey : IEquatable +{ + public LauncherContentKey( + ModificationType contentType, + string? parentIdentity, + string? name, + string? version) + { + ContentType = contentType; + ParentIdentity = parentIdentity; + Name = name; + Version = version; + } + + public static LauncherContentKey OriginalGame { get; } = + new(ModificationType.Mod, string.Empty, "Original Game", string.Empty); + + /// + /// Creates the name-only identity used by the top-level modification catalog. + /// + public static LauncherContentKey ForModificationName(string? name) + { + return new LauncherContentKey(ModificationType.Mod, string.Empty, name, string.Empty); + } + + public ModificationType ContentType { get; } + + [AllowNull] + public string ParentIdentity => field ?? string.Empty; + + [AllowNull] + public string Name => field ?? string.Empty; + + [AllowNull] + public string Version => field ?? string.Empty; + + /// + /// Gets the card identity for this content, omitting its version. + /// + internal LauncherContentKey WithoutVersion() + { + return new LauncherContentKey(ContentType, ParentIdentity, Name, string.Empty); + } + + /// + /// Determines whether this content belongs to the supplied parent. + /// + public bool IsChildOf(LauncherContentKey parent) + { + return IdentityTextEquals(ParentIdentity, parent.Name); + } + + /// + /// Determines whether this key has the supplied content name. + /// + public bool HasName(string? name) + { + return IdentityTextEquals(Name, name); + } + + /// + /// Formats the legacy-compatible lowercase identity used by launcher-owned integrity records. + /// + public string ToStableString() + { + return string.Join( + ":", + ContentType, + ParentIdentity, + Name, + Version).ToLowerInvariant(); + } + + public bool Equals(LauncherContentKey other) + { + return ContentType == other.ContentType && + IdentityTextEquals(ParentIdentity, other.ParentIdentity) && + IdentityTextEquals(Name, other.Name) && + IdentityTextEquals(Version, other.Version); + } + + public override bool Equals(object? obj) + { + return obj is LauncherContentKey other && Equals(other); + } + + public override int GetHashCode() + { + var hashCode = new HashCode(); + hashCode.Add(ContentType); + hashCode.Add(ParentIdentity, StringComparer.OrdinalIgnoreCase); + hashCode.Add(Name, StringComparer.OrdinalIgnoreCase); + hashCode.Add(Version, StringComparer.OrdinalIgnoreCase); + return hashCode.ToHashCode(); + } + + public static bool operator ==(LauncherContentKey left, LauncherContentKey right) + { + return left.Equals(right); + } + + public static bool operator !=(LauncherContentKey left, LauncherContentKey right) + { + return !left.Equals(right); + } + + private static bool IdentityTextEquals(string? left, string? right) + { + return StringComparer.OrdinalIgnoreCase.Equals(left ?? string.Empty, right ?? string.Empty); + } +} diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentTheme.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentTheme.cs new file mode 100644 index 00000000..f8dbe4e9 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentTheme.cs @@ -0,0 +1,93 @@ +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Describes the palette a published modification asks the launcher to wear while it is selected. +/// +/// +/// Property names match the remote manifest's ColorsInformation keys, which are also the launcher's own +/// resource key names. Values stay as the raw strings the backend published: they are colour literals in a +/// presentation format this layer deliberately knows nothing about, so parsing and per-slot fallback happen in the +/// UI where the palette is actually built. Any slot may be absent, and a modification may supply none at all. +/// +public sealed class LauncherContentTheme +{ + public string GenLauncherBorderColor { get; init; } = string.Empty; + + public string GenLauncherInactiveBorder { get; init; } = string.Empty; + + public string GenLauncherInactiveBorder2 { get; init; } = string.Empty; + + public string GenLauncherActiveColor { get; init; } = string.Empty; + + public string GenLauncherDarkFillColor { get; init; } = string.Empty; + + public string GenLauncherDarkBackGround { get; init; } = string.Empty; + + public string GenLauncherLightBackGround { get; init; } = string.Empty; + + public string GenLauncherDefaultTextColor { get; init; } = string.Empty; + + public string GenLauncherDownloadTextColor { get; init; } = string.Empty; + + /// + /// Gets the colour the list-selection gradient starts from. + /// + /// + /// The remote name is ...Color1. Upstream swapped this with ...Color2 on the manifest path only, + /// so a published mod's documented start colour actually rendered at the far stop. This launcher maps the + /// names as documented instead, which is the behaviour mod authors were writing against. + /// + public string GenLauncherListBoxSelectionColor1 { get; init; } = string.Empty; + + public string GenLauncherListBoxSelectionColor2 { get; init; } = string.Empty; + + public string GenLauncherButtonSelectionColor { get; init; } = string.Empty; + + /// + /// Gets the absolute URL of the artwork drawn behind the launcher shell. + /// + public string GenLauncherBackgroundImageLink { get; init; } = string.Empty; + + /// + /// Gets a value indicating whether this theme publishes at least one usable palette or artwork value. + /// + public bool HasValues => + !string.IsNullOrWhiteSpace(GenLauncherBorderColor) || + !string.IsNullOrWhiteSpace(GenLauncherInactiveBorder) || + !string.IsNullOrWhiteSpace(GenLauncherInactiveBorder2) || + !string.IsNullOrWhiteSpace(GenLauncherActiveColor) || + !string.IsNullOrWhiteSpace(GenLauncherDarkFillColor) || + !string.IsNullOrWhiteSpace(GenLauncherDarkBackGround) || + !string.IsNullOrWhiteSpace(GenLauncherLightBackGround) || + !string.IsNullOrWhiteSpace(GenLauncherDefaultTextColor) || + !string.IsNullOrWhiteSpace(GenLauncherDownloadTextColor) || + !string.IsNullOrWhiteSpace(GenLauncherListBoxSelectionColor1) || + !string.IsNullOrWhiteSpace(GenLauncherListBoxSelectionColor2) || + !string.IsNullOrWhiteSpace(GenLauncherButtonSelectionColor) || + !string.IsNullOrWhiteSpace(GenLauncherBackgroundImageLink); + + /// + /// Builds the cache file name a version's shell artwork is stored under. + /// + /// + /// This is the single authority for the name. The download cache writes it, the presentation layer reads it, + /// content removal deletes it, and integrity scanning skips it for inactive versions — they only agree because + /// they all call here. The suffix keeps it clear of the tile image, which is cached under the bare version. + /// + public static string ResolveBackgroundImageBaseName(string version) + { + return (version ?? string.Empty) + "-background"; + } + + /// + /// Builds the cache file name a version's palette is stored under, so it survives an offline restart. + /// + /// + /// The palette is cached beside the artwork it belongs to, which gives both the same lifetime: removing a + /// modification removes its cache folder and everything in it. + /// + public static string ResolveCacheBaseName(string version) + { + return (version ?? string.Empty) + "-theme"; + } +} diff --git a/GenLauncherGO.Core/Mods/Models/LauncherContentVersion.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentVersion.cs new file mode 100644 index 00000000..3c598971 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentVersion.cs @@ -0,0 +1,125 @@ +using System; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Describes immutable metadata for one launcher content version and its separate mutable local installation state. +/// +/// +/// Infrastructure maps third-party backend documents into this normalized domain model. Canonical identity is always +/// provided by ; object equality is intentionally not an identity mechanism. +/// +public sealed class LauncherContentVersion : IComparable +{ + public LauncherContentVersion() + : this(new LauncherContentInstallation()) + { + } + + public LauncherContentVersion(LauncherContentInstallation installation) + { + Installation = installation ?? throw new ArgumentNullException(nameof(installation)); + } + + public ModificationType ModificationType { get; init; } + + public string Name { get; init; } = string.Empty; + + public string Version { get; init; } = string.Empty; + + public string SimpleDownloadLink { get; init; } = string.Empty; + + // ReSharper disable once InconsistentNaming + public string UIImageSourceLink { get; init; } = string.Empty; + + public string DiscordLink { get; init; } = string.Empty; + + // ReSharper disable once InconsistentNaming + public string ModDBLink { get; init; } = string.Empty; + + public string NewsLink { get; init; } = string.Empty; + + public string ParentContentName { get; init; } = string.Empty; + + public string S3HostLink { get; init; } = string.Empty; + + public string S3BucketName { get; init; } = string.Empty; + + public string S3FolderName { get; init; } = string.Empty; + + public string S3HostPublicKey { get; init; } = string.Empty; + + public string S3HostSecretKey { get; init; } = string.Empty; + + public string NetworkInfo { get; init; } = string.Empty; + + public bool Deprecated { get; init; } + + public string SupportLink { get; init; } = string.Empty; + + /// + /// Gets the palette this version asks the launcher to wear while it is selected, when it published one. + /// + public LauncherContentTheme? Theme { get; init; } + + public LauncherContentInstallation Installation { get; init; } + + /// + /// Gets the content source kind after applying package metadata precedence. + /// + public ContentSourceKind EffectiveContentSourceKind => + ResolveContentSourceKind( + S3HostLink, + S3BucketName, + S3FolderName, + SimpleDownloadLink, + Installation.ContentSourceKind); + + public string DisplayName => string.Join(" ", new[] { Name, Version } + .Where(value => !string.IsNullOrWhiteSpace(value))); + + /// + /// Gets the canonical identity of this content version. + /// + public LauncherContentKey ContentKey => + new(ModificationType, ParentContentName, Name, Version); + + public int CompareTo(LauncherContentVersion? other) + { + return other is null + ? 1 + : LauncherContentVersionComparer.Instance.Compare(Version, other.Version); + } + + public override string ToString() + { + return Name; + } + + /// + /// Resolves the content source kind from package metadata. + /// + 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/LauncherContentVersionComparer.cs b/GenLauncherGO.Core/Mods/Models/LauncherContentVersionComparer.cs new file mode 100644 index 00000000..9f2306c9 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherContentVersionComparer.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Compares launcher content version labels without converting their digits to a bounded integer. +/// +/// +/// The remote catalog does not define a semantic-version contract. For compatibility, comparison retains the legacy +/// numeric projection: ASCII digits are compared in encounter order, the shorter projection is right-padded with +/// zeroes, labels without ASCII digits compare below numeric labels, and two labels without digits compare equally. +/// Punctuation and suffix text therefore do not define precedence. This comparer makes that boundary explicit while +/// avoiding integer overflow for arbitrarily long digit sequences. +/// +internal sealed class LauncherContentVersionComparer : IComparer +{ + private LauncherContentVersionComparer() + { + } + + /// + /// Gets the shared launcher content version comparer. + /// + public static LauncherContentVersionComparer Instance { get; } = new(); + + public int Compare(string? x, string? y) + { + string leftDigits = ExtractAsciiDigits(x); + string rightDigits = ExtractAsciiDigits(y); + + if (leftDigits.Length == 0 || rightDigits.Length == 0) + { + return leftDigits.Length.CompareTo(rightDigits.Length); + } + + int projectedLength = Math.Max(leftDigits.Length, rightDigits.Length); + return string.CompareOrdinal( + leftDigits.PadRight(projectedLength, '0'), + rightDigits.PadRight(projectedLength, '0')); + } + + private static string ExtractAsciiDigits(string? value) + { + return value is null + ? string.Empty + : new string(value.Where(IsAsciiDigit).ToArray()); + } + + private static bool IsAsciiDigit(char character) + { + return character is >= '0' and <= '9'; + } +} diff --git a/GenLauncherGO.Core/Mods/Models/LauncherData.cs b/GenLauncherGO.Core/Mods/Models/LauncherData.cs new file mode 100644 index 00000000..553cbcdd --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/LauncherData.cs @@ -0,0 +1,235 @@ +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 +{ + private readonly List _addons = []; + private readonly List _modifications = []; + private readonly List _patches = []; + + public LauncherData() + { + Addons = _addons.AsReadOnly(); + Modifications = _modifications.AsReadOnly(); + Patches = _patches.AsReadOnly(); + } + + public IReadOnlyList Addons { get; } + + public IReadOnlyList Modifications { get; } + + public IReadOnlyList Patches { get; } + + /// + /// Enumerates every modification, add-on, and patch in catalog order. + /// + public IEnumerable AllContent => _modifications.Concat(_addons).Concat(_patches); + + public LauncherContent? GetSelectedMod() + { + return _modifications.FirstOrDefault(modification => modification.IsSelected); + } + + /// + /// Gets patches associated with the supplied modification, or the original game when none is supplied. + /// + public IReadOnlyList GetPatchesFor(LauncherContent? modification) + { + LauncherContentKey parentKey = modification?.ContentKey ?? LauncherContentKey.OriginalGame; + + return _patches + .Where(patch => patch.ContentKey.IsChildOf(parentKey)) + .ToList(); + } + + /// + /// Gets add-ons associated with the supplied modification or patch. + /// + public IReadOnlyList GetAddonsFor( + LauncherContent? modification, + LauncherContent? patch) + { + LauncherContentKey parentKey = modification?.ContentKey ?? LauncherContentKey.OriginalGame; + LauncherContentKey? patchKey = patch?.ContentKey; + + // Direct add-ons stay ahead of patch add-ons regardless of catalog insertion order. + return _addons + .Where(addon => addon.ContentKey.IsChildOf(parentKey)) + .Union(_addons.Where(addon => + patchKey.HasValue && + addon.ContentKey.IsChildOf(patchKey.Value))) + .ToList(); + } + + public IReadOnlyList GetAllModificationVersions() + { + return _modifications + .SelectMany(modification => modification.Versions) + .ToList(); + } + + public LauncherContent? FindContent(LauncherContentKey contentKey) + { + List? contentStorage = GetContentStorage(contentKey.ContentType); + return contentStorage is null + ? null + : FindContentCard(contentStorage, contentKey); + } + + /// + /// Adds a supported content version or merges it into the matching content card. + /// + public void AddOrUpdate(LauncherContentVersion version) + { + ArgumentNullException.ThrowIfNull(version); + + if (version.ModificationType == ModificationType.Addon && + string.IsNullOrEmpty(version.ParentContentName)) + { + return; + } + + List? contentStorage = GetContentStorage(version.ModificationType); + if (contentStorage != null) + { + AddOrUpdateContentVersion(contentStorage, version); + } + } + + /// + /// Deletes a version and removes dependent patch or add-on cards when their parent is removed. + /// + public void DeleteVersion(LauncherContentKey contentKey) + { + List? contentStorage = GetContentStorage(contentKey.ContentType); + if (contentStorage is null) + { + return; + } + + bool removedContentCard = DeleteContentVersion(contentStorage, contentKey); + if (!removedContentCard) + { + return; + } + + DeleteDependentContent(contentKey); + } + + /// + /// Deletes an entire content card and any patch or add-on cards that depend on it. + /// + public void DeleteContent(LauncherContentKey contentKey) + { + List? contentStorage = GetContentStorage(contentKey.ContentType); + if (contentStorage is null) + { + return; + } + + LauncherContent? content = FindContentCard(contentStorage, contentKey); + if (content is null) + { + return; + } + + contentStorage.Remove(content); + DeleteDependentContent(contentKey); + } + + private void DeleteDependentContent(LauncherContentKey contentKey) + { + if (contentKey.ContentType == ModificationType.Mod) + { + var dependentPatches = _patches + .Where(patch => IsDependentOn(patch, contentKey)) + .ToList(); + + foreach (LauncherContent patch in dependentPatches) + { + DeleteDependentAddons(patch.ContentKey); + _patches.Remove(patch); + } + } + + if (contentKey.ContentType is ModificationType.Mod or ModificationType.Patch) + { + DeleteDependentAddons(contentKey); + } + } + + private static void AddOrUpdateContentVersion( + List contentStorage, + LauncherContentVersion version) + { + LauncherContent? savedContent = FindContentCard(contentStorage, version.ContentKey); + + if (savedContent != null) + { + savedContent.AddOrMergeVersion(version); + } + else + { + contentStorage.Add(new LauncherContent(version)); + } + } + + private static bool DeleteContentVersion( + List contentStorage, + LauncherContentKey contentKey) + { + LauncherContent? savedContent = FindContentCard(contentStorage, contentKey); + if (savedContent is null) + { + return false; + } + + savedContent.RemoveVersion(contentKey); + + if (savedContent.Versions.Count == 0) + { + contentStorage.Remove(savedContent); + return true; + } + + return false; + } + + private static LauncherContent? FindContentCard( + List contentStorage, + LauncherContentKey contentKey) + { + LauncherContentKey cardKey = contentKey.WithoutVersion(); + return contentStorage.Find(content => content.ContentKey == cardKey); + } + + private void DeleteDependentAddons(LauncherContentKey parentKey) + { + _addons.RemoveAll(addon => IsDependentOn(addon, parentKey)); + } + + private static bool IsDependentOn( + LauncherContent modification, + LauncherContentKey parentKey) + { + return !string.IsNullOrWhiteSpace(parentKey.Name) && + modification.ContentKey.IsChildOf(parentKey); + } + + private List? GetContentStorage(ModificationType contentType) + { + return contentType switch + { + ModificationType.Mod => _modifications, + ModificationType.Addon => _addons, + ModificationType.Patch => _patches, + _ => null + }; + } +} diff --git a/GenLauncherGO.Core/Mods/Models/ModificationImageReplacementRequest.cs b/GenLauncherGO.Core/Mods/Models/ModificationImageReplacementRequest.cs new file mode 100644 index 00000000..ca6cb097 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/ModificationImageReplacementRequest.cs @@ -0,0 +1,26 @@ +using System; + +namespace GenLauncherGO.Core.Mods.Models; + +public sealed class ModificationImageReplacementRequest +{ + public ModificationImageReplacementRequest( + string modificationName, + string imageBaseName, + string sourceImagePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(modificationName); + ArgumentException.ThrowIfNullOrWhiteSpace(imageBaseName); + ArgumentException.ThrowIfNullOrWhiteSpace(sourceImagePath); + + ModificationName = modificationName; + ImageBaseName = imageBaseName; + SourceImagePath = sourceImagePath; + } + + public string ModificationName { get; } + + public string ImageBaseName { get; } + + public string SourceImagePath { get; } +} diff --git a/GenLauncherGO.Core/Mods/Models/ModificationType.cs b/GenLauncherGO.Core/Mods/Models/ModificationType.cs new file mode 100644 index 00000000..4753eaf3 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/ModificationType.cs @@ -0,0 +1,27 @@ +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Identifies a launcher content category. +/// +public enum ModificationType +{ + /// + /// A game modification. + /// + Mod = 0, + + /// + /// An add-on for a game modification or patch. + /// + Addon = 1, + + /// + /// A patch for a game modification or the original game. + /// + Patch = 2, + + /// + /// Advertising content displayed in the launcher. + /// + Advertising = 3 +} diff --git a/GenLauncherGO.Core/Mods/Models/OwnedContentPath.cs b/GenLauncherGO.Core/Mods/Models/OwnedContentPath.cs new file mode 100644 index 00000000..ad10520e --- /dev/null +++ b/GenLauncherGO.Core/Mods/Models/OwnedContentPath.cs @@ -0,0 +1,39 @@ +using System; +using GenLauncherGO.Core.IO; + +namespace GenLauncherGO.Core.Mods.Models; + +/// +/// Identifies one normalized launcher-owned content path together with the root that owns it. +/// +public sealed record OwnedContentPath +{ + /// + /// Initializes a new instance of the record. + /// + /// + /// Thrown when either path is missing or is not below + /// . + /// + public OwnedContentPath(string ownerRoot, string fullPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ownerRoot); + ArgumentException.ThrowIfNullOrWhiteSpace(fullPath); + + string normalizedOwnerRoot = LexicalPath.NormalizeFullPath(ownerRoot); + string normalizedFullPath = LexicalPath.NormalizeFullPath(fullPath); + if (!LexicalPath.IsPathBelowDirectory(normalizedFullPath, normalizedOwnerRoot)) + { + throw new ArgumentException("An owned content path must be below its owning root.", nameof(fullPath)); + } + + OwnerRoot = normalizedOwnerRoot; + FullPath = normalizedFullPath; + } + + public string OwnerRoot { get; } + + public string FullPath { get; } + + public string RelativePath => LexicalPath.GetRelativePath(OwnerRoot, FullPath); +} diff --git a/GenLauncherGO.Core/Mods/Services/LauncherContentPathResolver.cs b/GenLauncherGO.Core/Mods/Services/LauncherContentPathResolver.cs new file mode 100644 index 00000000..20f1d172 --- /dev/null +++ b/GenLauncherGO.Core/Mods/Services/LauncherContentPathResolver.cs @@ -0,0 +1,94 @@ +using System; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Mods.Services; + +/// +/// Resolves launcher-owned content paths from canonical content identities. +/// +public static class LauncherContentPathResolver +{ + /// + /// Resolves the owned installed version directory, or returns for an incomplete or + /// unsupported identity. + /// + public static OwnedContentPath? ResolveVersionPath( + LauncherPaths paths, + LauncherContentKey contentKey) + { + ArgumentNullException.ThrowIfNull(paths); + if (string.IsNullOrWhiteSpace(contentKey.Version)) + { + return null; + } + + OwnedContentPath? contentPath = ResolveContentPath(paths, contentKey); + if (contentPath is null) + { + return null; + } + + string safeVersion = LexicalPath.NormalizePathSegment(contentKey.Version, nameof(contentKey.Version)); + string versionPath = LexicalPath.ResolvePath(contentPath.FullPath, safeVersion); + return new OwnedContentPath(contentPath.OwnerRoot, versionPath); + } + + /// + /// Resolves the owned content-card directory, or returns for an incomplete identity. + /// + public static OwnedContentPath? ResolveContentPath( + LauncherPaths paths, + LauncherContentKey contentKey) + { + ArgumentNullException.ThrowIfNull(paths); + string[]? pathSegments = ResolveContentPathSegments(contentKey); + return pathSegments is null + ? null + : ResolvePackagePath(paths.ModsDirectory, pathSegments); + } + + /// + /// Resolves the owned cleanup root, or returns for an incomplete identity. + /// + public static OwnedContentPath? ResolveCleanupRootPath( + LauncherPaths paths, + LauncherContentKey contentKey) + { + ArgumentNullException.ThrowIfNull(paths); + string[]? pathSegments = ResolveContentPathSegments(contentKey); + return pathSegments is null + ? null + : ResolvePackagePath(paths.ModsDirectory, pathSegments[0]); + } + + private static string[]? ResolveContentPathSegments(LauncherContentKey contentKey) + { + if (string.IsNullOrWhiteSpace(contentKey.Name)) + { + return null; + } + + return contentKey.ContentType switch + { + ModificationType.Addon when !string.IsNullOrWhiteSpace(contentKey.ParentIdentity) => + [contentKey.ParentIdentity, LauncherFileSystemLayout.AddonsFolderName, contentKey.Name], + ModificationType.Patch when !string.IsNullOrWhiteSpace(contentKey.ParentIdentity) => + [contentKey.ParentIdentity, LauncherFileSystemLayout.PatchesFolderName, contentKey.Name], + ModificationType.Mod => [contentKey.Name], + _ => null + }; + } + + private static OwnedContentPath ResolvePackagePath(string modsDirectory, params string?[] segments) + { + string[] safeSegments = segments + .Select((segment, index) => LexicalPath.NormalizePathSegment(segment, $"segment{index}")) + .ToArray(); + string fullPath = LexicalPath.ResolvePath(modsDirectory, Path.Combine(safeSegments)); + return new OwnedContentPath(modsDirectory, fullPath); + } +} diff --git a/GenLauncherGO.Core/Properties/AssemblyInfo.cs b/GenLauncherGO.Core/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..63d47e5d --- /dev/null +++ b/GenLauncherGO.Core/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System.Runtime.CompilerServices; + +// Lets Core types stay internal when only the test suite needs to reach them, so +// visibility reflects production consumption rather than test access. Matches the +// Infrastructure and UI assemblies. +[assembly: InternalsVisibleTo("GenLauncherGO.Tests")] diff --git a/GenLauncherGO.Core/Remote/IRemoteConnectionProbe.cs b/GenLauncherGO.Core/Remote/IRemoteConnectionProbe.cs new file mode 100644 index 00000000..1e936ca9 --- /dev/null +++ b/GenLauncherGO.Core/Remote/IRemoteConnectionProbe.cs @@ -0,0 +1,16 @@ +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. + /// + Task CanConnectAsync(Uri endpointUri, CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Settings/Contracts/ILauncherPreferencesService.cs b/GenLauncherGO.Core/Settings/Contracts/ILauncherPreferencesService.cs new file mode 100644 index 00000000..e99aaab8 --- /dev/null +++ b/GenLauncherGO.Core/Settings/Contracts/ILauncherPreferencesService.cs @@ -0,0 +1,30 @@ +using System; +using GenLauncherGO.Core.Settings.Exceptions; +using GenLauncherGO.Core.Settings.Models; + +namespace GenLauncherGO.Core.Settings.Contracts; + +/// +/// Provides the current launcher preferences and persists preference updates. +/// +public interface ILauncherPreferencesService +{ + /// + /// Gets the current launcher preferences. + /// + LauncherPreferences Current { get; } + + /// + /// Occurs after launcher preferences have changed. + /// + event EventHandler? PreferencesChanged; + + /// + /// Persists the supplied launcher preferences and publishes the updated state. + /// + /// + /// Thrown when the requested preferences cannot be persisted. In that case, + /// and remain unchanged. + /// + void Update(LauncherPreferences preferences); +} diff --git a/GenLauncherGO.Core/Settings/Exceptions/LauncherPreferencesPersistenceException.cs b/GenLauncherGO.Core/Settings/Exceptions/LauncherPreferencesPersistenceException.cs new file mode 100644 index 00000000..ff59bf01 --- /dev/null +++ b/GenLauncherGO.Core/Settings/Exceptions/LauncherPreferencesPersistenceException.cs @@ -0,0 +1,12 @@ +using System; + +namespace GenLauncherGO.Core.Settings.Exceptions; + +public sealed class LauncherPreferencesPersistenceException : Exception +{ + public LauncherPreferencesPersistenceException(Exception innerException) + : base("Launcher preferences could not be persisted.", innerException) + { + ArgumentNullException.ThrowIfNull(innerException); + } +} diff --git a/GenLauncherGO.Core/Settings/Models/LauncherCustomExecutable.cs b/GenLauncherGO.Core/Settings/Models/LauncherCustomExecutable.cs new file mode 100644 index 00000000..080aa912 --- /dev/null +++ b/GenLauncherGO.Core/Settings/Models/LauncherCustomExecutable.cs @@ -0,0 +1,19 @@ +using System; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Settings.Models; + +public sealed record LauncherCustomExecutable +{ + public LauncherCustomExecutable(string displayName, string executableName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(displayName); + + DisplayName = displayName.Trim(); + ExecutableName = LauncherFileSystemLayout.NormalizeExecutableFileName(executableName); + } + + public string DisplayName { get; } + + public string ExecutableName { get; } +} diff --git a/GenLauncherGO.Core/Settings/Models/LauncherGamePreferences.cs b/GenLauncherGO.Core/Settings/Models/LauncherGamePreferences.cs new file mode 100644 index 00000000..e3069e9f --- /dev/null +++ b/GenLauncherGO.Core/Settings/Models/LauncherGamePreferences.cs @@ -0,0 +1,92 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Launching.Models; + +namespace GenLauncherGO.Core.Settings.Models; + +public sealed record LauncherGamePreferences +{ + public int LaunchesCount { get; init; } + + public string SelectedGameClient { get; init; } = string.Empty; + + public string SelectedWorldBuilder { get; init; } = string.Empty; + + public string GameArguments { get; init; } = string.Empty; + + public string WorldBuilderArguments { get; init; } = string.Empty; + + public double ModsListVerticalOffset { get; init; } + + /// + /// Gets the row the advertising tile occupies in the modification list. The tile is rebuilt from the remote + /// catalog on every start, so unlike a modification it has no persisted card of its own to carry its position. + /// + public int AdvertisingPositionInList { get; init; } + + public IReadOnlyList CustomGameClients { get; init; } = + Array.Empty(); + + public IReadOnlyList CustomWorldBuilders { get; init; } = + Array.Empty(); + + /// + /// Gets the custom executable registrations for one launch target. + /// + public IReadOnlyList GetCustomExecutables(GameLaunchTargetKind targetKind) + { + return targetKind switch + { + GameLaunchTargetKind.GameClient => CustomGameClients, + GameLaunchTargetKind.WorldBuilder => CustomWorldBuilders, + _ => throw new ArgumentOutOfRangeException(nameof(targetKind), targetKind, "Unknown launch target.") + }; + } + + /// + /// Gets the selected executable name for one launch target. + /// + public string GetSelectedExecutable(GameLaunchTargetKind targetKind) + { + return targetKind switch + { + GameLaunchTargetKind.GameClient => SelectedGameClient, + GameLaunchTargetKind.WorldBuilder => SelectedWorldBuilder, + _ => throw new ArgumentOutOfRangeException(nameof(targetKind), targetKind, "Unknown launch target.") + }; + } + + /// + /// Replaces the custom executable registrations for one launch target. + /// + public LauncherGamePreferences WithCustomExecutables( + GameLaunchTargetKind targetKind, + IReadOnlyList executables) + { + ArgumentNullException.ThrowIfNull(executables); + + return targetKind switch + { + GameLaunchTargetKind.GameClient => this with { CustomGameClients = executables }, + GameLaunchTargetKind.WorldBuilder => this with { CustomWorldBuilders = executables }, + _ => throw new ArgumentOutOfRangeException(nameof(targetKind), targetKind, "Unknown launch target.") + }; + } + + /// + /// Replaces the selected executable name for one launch target. + /// + public LauncherGamePreferences WithSelectedExecutable( + GameLaunchTargetKind targetKind, + string executableName) + { + ArgumentNullException.ThrowIfNull(executableName); + + return targetKind switch + { + GameLaunchTargetKind.GameClient => this with { SelectedGameClient = executableName }, + GameLaunchTargetKind.WorldBuilder => this with { SelectedWorldBuilder = executableName }, + _ => throw new ArgumentOutOfRangeException(nameof(targetKind), targetKind, "Unknown launch target.") + }; + } +} diff --git a/GenLauncherGO.Core/Settings/Models/LauncherGamePreferencesSet.cs b/GenLauncherGO.Core/Settings/Models/LauncherGamePreferencesSet.cs new file mode 100644 index 00000000..d129698d --- /dev/null +++ b/GenLauncherGO.Core/Settings/Models/LauncherGamePreferencesSet.cs @@ -0,0 +1,28 @@ +using System; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Settings.Models; + +public sealed record LauncherGamePreferencesSet +{ + public LauncherGamePreferences Generals { get; init; } = new(); + + public LauncherGamePreferences ZeroHour { get; init; } = new(); + + public LauncherGamePreferences Get(SupportedGame game) + { + return PerGame.Select(game, Generals, ZeroHour, nameof(game)); + } + + public LauncherGamePreferencesSet With(SupportedGame game, LauncherGamePreferences preferences) + { + ArgumentNullException.ThrowIfNull(preferences); + + return game switch + { + SupportedGame.Generals => this with { Generals = preferences }, + SupportedGame.ZeroHour => this with { ZeroHour = preferences }, + _ => throw PerGame.Unsupported(game, nameof(game)) + }; + } +} diff --git a/GenLauncherGO.Core/Settings/Models/LauncherInstallations.cs b/GenLauncherGO.Core/Settings/Models/LauncherInstallations.cs new file mode 100644 index 00000000..6bd7df26 --- /dev/null +++ b/GenLauncherGO.Core/Settings/Models/LauncherInstallations.cs @@ -0,0 +1,51 @@ +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Settings.Models; + +public sealed record LauncherInstallations +{ + public string? Generals { get; init; } + + public string? ZeroHour { get; init; } + + public string? GetPath(SupportedGame game) + { + return PerGame.Select(game, Generals, ZeroHour, nameof(game)); + } + + public LauncherInstallations WithPath(SupportedGame game, string? path) + { + return game switch + { + SupportedGame.Generals => this with { Generals = path }, + SupportedGame.ZeroHour => this with { ZeroHour = path }, + _ => throw PerGame.Unsupported(game, nameof(game)) + }; + } + + /// + /// Resolves the preferred configured game, falling back when exactly one installation is available. + /// + public SupportedGame? ResolvePreferredGame(SupportedGame? preferredGame) + { + bool hasGenerals = !string.IsNullOrWhiteSpace(Generals); + bool hasZeroHour = !string.IsNullOrWhiteSpace(ZeroHour); + + if (preferredGame == SupportedGame.Generals && hasGenerals) + { + return SupportedGame.Generals; + } + + if (preferredGame == SupportedGame.ZeroHour && hasZeroHour) + { + return SupportedGame.ZeroHour; + } + + if (hasGenerals == hasZeroHour) + { + return null; + } + + return hasGenerals ? SupportedGame.Generals : SupportedGame.ZeroHour; + } +} diff --git a/GenLauncherGO.Core/Settings/Models/LauncherPreferences.cs b/GenLauncherGO.Core/Settings/Models/LauncherPreferences.cs new file mode 100644 index 00000000..cc09415a --- /dev/null +++ b/GenLauncherGO.Core/Settings/Models/LauncherPreferences.cs @@ -0,0 +1,14 @@ +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Core.Settings.Models; + +public sealed record LauncherPreferences +{ + public LauncherInstallations Installations { get; init; } = new(); + + public SupportedGame? LastSelectedGame { get; init; } + + public LauncherSharedPreferences Shared { get; init; } = new(); + + public LauncherGamePreferencesSet Games { get; init; } = new(); +} diff --git a/GenLauncherGO.Core/Settings/Models/LauncherSharedPreferences.cs b/GenLauncherGO.Core/Settings/Models/LauncherSharedPreferences.cs new file mode 100644 index 00000000..be468442 --- /dev/null +++ b/GenLauncherGO.Core/Settings/Models/LauncherSharedPreferences.cs @@ -0,0 +1,14 @@ +namespace GenLauncherGO.Core.Settings.Models; + +public sealed record LauncherSharedPreferences +{ + public bool AutoDeleteOldVersions { get; init; } + + public bool HideLauncherAfterGameStart { get; init; } + + public bool EnableDiagnosticLogging { get; init; } + + public bool UseEnglishLanguage { get; init; } + + public bool HasShownRetailGenPatcherRecommendation { get; init; } +} diff --git a/GenLauncherGO.Core/Shell/Contracts/ILauncherShellService.cs b/GenLauncherGO.Core/Shell/Contracts/ILauncherShellService.cs new file mode 100644 index 00000000..0370e6ee --- /dev/null +++ b/GenLauncherGO.Core/Shell/Contracts/ILauncherShellService.cs @@ -0,0 +1,20 @@ +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. + /// + void OpenUri(string uri); + + /// + /// Opens a folder with the operating system shell. + /// + void OpenFolder( + string folderPath, + bool requireFiles = false, + bool createIfMissing = false); +} diff --git a/GenLauncherGO.Core/Startup/Contracts/IGameInstallationService.cs b/GenLauncherGO.Core/Startup/Contracts/IGameInstallationService.cs new file mode 100644 index 00000000..61e5549b --- /dev/null +++ b/GenLauncherGO.Core/Startup/Contracts/IGameInstallationService.cs @@ -0,0 +1,96 @@ +using System; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Startup.Models; + +namespace GenLauncherGO.Core.Startup.Contracts; + +/// +/// Validates user-selected game directories and discovers valid Windows installations. +/// +public interface IGameInstallationService +{ + /// + /// Finds a supported game root at or above the launcher executable directory, or returns + /// for a standalone launcher. + /// + GameInstallationLocation? FindContainingInstallation(string executableDirectory); + + /// + /// Validates that one selected root is an existing safe directory whose relationship to the launcher is allowed + /// and which contains at least one built-in executable for the selected game. + /// + GameInstallationValidationResult Validate( + SupportedGame game, + string? directory, + string executableDirectory); + + /// + /// Retains valid configured paths and fills only missing or invalid paths from trusted registry views. + /// + LauncherInstallations DiscoverValidInstallations( + LauncherInstallations current, + string executableDirectory); +} + +/// +/// Applies installation-set invariants on top of the canonical per-game validation boundary. +/// +public static class GameInstallationServiceExtensions +{ + /// + /// Validates and canonicalizes the complete supported installation set. + /// Empty paths remain optional, but every nonempty path must be valid, at least one installation must remain, + /// and the two games must not resolve to the same physical directory. + /// + public static LauncherInstallationsValidationResult ValidateInstallations( + this IGameInstallationService installationService, + LauncherInstallations installations, + string executableDirectory) + { + ArgumentNullException.ThrowIfNull(installationService); + ArgumentNullException.ThrowIfNull(installations); + ArgumentException.ThrowIfNullOrWhiteSpace(executableDirectory); + + GameInstallationValidationResult generals = installationService.Validate( + SupportedGame.Generals, + installations.Generals, + executableDirectory); + GameInstallationValidationResult zeroHour = installationService.Validate( + SupportedGame.ZeroHour, + installations.ZeroHour, + executableDirectory); + + string? enteredGeneralsPath = installations.Generals?.Trim(); + string? enteredZeroHourPath = installations.ZeroHour?.Trim(); + bool hasGeneralsPath = !string.IsNullOrEmpty(enteredGeneralsPath); + bool hasZeroHourPath = !string.IsNullOrEmpty(enteredZeroHourPath); + bool hasInvalidNonemptyPath = + (hasGeneralsPath && !generals.IsValid) || + (hasZeroHourPath && !zeroHour.IsValid); + bool hasIdenticalEnteredPath = + hasGeneralsPath && + hasZeroHourPath && + LexicalPath.AreEquivalent(enteredGeneralsPath, enteredZeroHourPath); + bool hasDuplicateCanonicalPath = + generals is { IsValid: true, CanonicalPath: not null } && + zeroHour is { IsValid: true, CanonicalPath: not null } && + LexicalPath.AreEquivalent(generals.CanonicalPath, zeroHour.CanonicalPath); + bool hasDuplicatePath = hasIdenticalEnteredPath || hasDuplicateCanonicalPath; + var canonicalInstallations = new LauncherInstallations + { + Generals = generals.IsValid ? generals.CanonicalPath : null, + ZeroHour = zeroHour.IsValid ? zeroHour.CanonicalPath : null + }; + bool isValid = !hasInvalidNonemptyPath && + !hasDuplicatePath && + (generals.IsValid || zeroHour.IsValid); + + return new LauncherInstallationsValidationResult( + generals, + zeroHour, + canonicalInstallations, + hasDuplicatePath, + isValid); + } +} diff --git a/GenLauncherGO.Core/Startup/Contracts/ILauncherHostEnvironmentService.cs b/GenLauncherGO.Core/Startup/Contracts/ILauncherHostEnvironmentService.cs new file mode 100644 index 00000000..ef73f9cd --- /dev/null +++ b/GenLauncherGO.Core/Startup/Contracts/ILauncherHostEnvironmentService.cs @@ -0,0 +1,40 @@ +using System; +using GenLauncherGO.Core.Startup.Models; + +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. + /// + string GetExecutableDirectory(); + + /// + /// Returns whether the current process is running with elevated administrator privileges. + /// + bool IsCurrentProcessElevated(); + + /// + /// Returns whether a directory is under a protected Program Files location. + /// + bool IsProtectedProgramFilesDirectory(string directory); + + /// + /// Attempts to start a replacement instance of the current launcher process. + /// + LauncherRestartResult TryRestartCurrentProcess(); + + /// + /// Attempts to acquire the launcher single-instance guard; the returned guard reports whether startup 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..390c9ad4 --- /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/ILauncherPathResolver.cs b/GenLauncherGO.Core/Startup/ILauncherPathResolver.cs new file mode 100644 index 00000000..2c9a8f3f --- /dev/null +++ b/GenLauncherGO.Core/Startup/ILauncherPathResolver.cs @@ -0,0 +1,22 @@ +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. + /// + LauncherStoragePaths Resolve(string executableDirectory); + + /// + /// Creates the shared launcher-owned directories. + /// + void PrepareLauncherDirectories(LauncherStoragePaths paths); + + /// + /// Creates launcher-owned directories for one supported game and optionally clears its temporary files. + /// + void PrepareGameDirectories(LauncherPaths paths, bool cleanTemporaryDirectory); +} diff --git a/GenLauncherGO.Core/Startup/LauncherFileSystemLayout.cs b/GenLauncherGO.Core/Startup/LauncherFileSystemLayout.cs new file mode 100644 index 00000000..03ec6c9a --- /dev/null +++ b/GenLauncherGO.Core/Startup/LauncherFileSystemLayout.cs @@ -0,0 +1,138 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GenLauncherGO.Core.IO; + +namespace GenLauncherGO.Core.Startup; + +/// +/// Defines the canonical launcher-owned folder layout and supported game file names. +/// +public static class LauncherFileSystemLayout +{ + public const string LauncherDataFolderName = "GenLauncherGO Data"; + + internal const string GeneralsDataFolderName = "C&C Generals Data"; + + internal const string ZeroHourDataFolderName = "C&C Zero Hour Data"; + + internal const string RuntimeFolderName = "Runtime"; + + internal const string CacheFolderName = "Cache"; + + internal const string ImagesFolderName = "Images"; + + internal const string ModsFolderName = "Mods"; + + internal const string LogsFolderName = "Logs"; + + internal const string TempFolderName = "Temp"; + + internal const string DeploymentFolderName = "Deployment"; + + internal const string IntegrityFolderName = "Integrity"; + + internal const string StateFolderName = "State"; + + internal const string PackageBackupsFolderName = "PackageBackups"; + + public const string PackagesFolderName = "Packages"; + + public const string AddonsFolderName = "Addons"; + + public const string PatchesFolderName = "Patches"; + + public const string ZeroHourCommunityExecutableFileName = "generalszh.exe"; + + public const string GeneralsCommunityExecutableFileName = "generalsv.exe"; + + public const string GeneralsOnlineExecutableFileName = "generalsonlinezh.exe"; + + public const string RetailGameExecutableFileName = "generals.exe"; + + public const string ZeroHourCommunityWorldBuilderExecutableFileName = "worldbuilderzh.exe"; + + public const string GeneralsCommunityWorldBuilderExecutableFileName = "worldbuilderv.exe"; + + public const string RetailWorldBuilderExecutableFileName = "WorldBuilder.exe"; + + private static readonly IReadOnlyList _generalsGameExecutableNames = Array.AsReadOnly( + [ + GeneralsCommunityExecutableFileName, + RetailGameExecutableFileName + ]); + + private static readonly IReadOnlyList _zeroHourGameExecutableNames = Array.AsReadOnly( + [ + GeneralsOnlineExecutableFileName, + ZeroHourCommunityExecutableFileName, + RetailGameExecutableFileName + ]); + + private static readonly IReadOnlyList _generalsWorldBuilderExecutableNames = Array.AsReadOnly( + [ + RetailWorldBuilderExecutableFileName, + GeneralsCommunityWorldBuilderExecutableFileName + ]); + + private static readonly IReadOnlyList _zeroHourWorldBuilderExecutableNames = Array.AsReadOnly( + [ + RetailWorldBuilderExecutableFileName, + ZeroHourCommunityWorldBuilderExecutableFileName + ]); + + /// + /// Gets the built-in game executables accepted for a managed game, in launcher display order. + /// + public static IReadOnlyList GetBuiltInGameExecutableNames(SupportedGame managedGame) + { + return PerGame.Select( + managedGame, + _generalsGameExecutableNames, + _zeroHourGameExecutableNames, + nameof(managedGame)); + } + + /// + /// Gets the built-in World Builder executables accepted for a managed game, in launcher display order. + /// + public static IReadOnlyList GetBuiltInWorldBuilderExecutableNames(SupportedGame managedGame) + { + return PerGame.Select( + managedGame, + _generalsWorldBuilderExecutableNames, + _zeroHourWorldBuilderExecutableNames, + nameof(managedGame)); + } + + /// + /// Normalizes a root-level Windows executable file name. + /// + /// + /// Thrown when the value is not a safe root-level .exe file name. + /// + public static string NormalizeExecutableFileName(string? executableName) + { + string normalizedName = LexicalPath.NormalizePathSegment( + executableName, + nameof(executableName)); + if (!string.Equals(Path.GetExtension(normalizedName), ".exe", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException("Executable file names must use the .exe extension.", nameof(executableName)); + } + + return normalizedName; + } + + /// + /// Gets the launcher-owned per-title directory name for a supported game. + /// + internal static string GetGameDataFolderName(SupportedGame managedGame) + { + return PerGame.Select( + managedGame, + GeneralsDataFolderName, + ZeroHourDataFolderName, + nameof(managedGame)); + } +} diff --git a/GenLauncherGO.Core/Startup/LauncherPaths.cs b/GenLauncherGO.Core/Startup/LauncherPaths.cs new file mode 100644 index 00000000..c661168e --- /dev/null +++ b/GenLauncherGO.Core/Startup/LauncherPaths.cs @@ -0,0 +1,163 @@ +using System; +using System.IO; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Startup; + +/// +/// Describes one supported game installation and its isolated launcher-owned data directory. +/// +public sealed record LauncherPaths +{ + private const string LauncherDataFileName = "LauncherData.yaml"; + + private const string OwnedPathContainmentFailureMessage = + "The resolved path must stay inside the launcher-owned directory."; + + internal LauncherPaths(SupportedGame game, string gameDirectory, string ownedGameDataDirectory) + { + PerGame.EnsureSupported(game, nameof(game)); + + Game = game; + GameDirectory = LexicalPath.NormalizeFullPath(gameDirectory); + OwnedGameDataDirectory = LexicalPath.NormalizeFullPath(ownedGameDataDirectory); + } + + public SupportedGame Game { get; } + + public string GameDirectory { get; } + + public string OwnedGameDataDirectory { get; } + + public string RuntimeDirectory => Path.Combine(OwnedGameDataDirectory, LauncherFileSystemLayout.RuntimeFolderName); + + public string CacheDirectory => Path.Combine(RuntimeDirectory, LauncherFileSystemLayout.CacheFolderName); + + public string ImagesDirectory => Path.Combine(CacheDirectory, LauncherFileSystemLayout.ImagesFolderName); + + public string ModsDirectory => Path.Combine(OwnedGameDataDirectory, LauncherFileSystemLayout.ModsFolderName); + + public string TempDirectory => Path.Combine(RuntimeDirectory, LauncherFileSystemLayout.TempFolderName); + + public string DeploymentDirectory => Path.Combine(RuntimeDirectory, LauncherFileSystemLayout.DeploymentFolderName); + + public string StateDirectory => Path.Combine(RuntimeDirectory, LauncherFileSystemLayout.StateFolderName); + + public string IntegrityDirectory => Path.Combine(RuntimeDirectory, LauncherFileSystemLayout.IntegrityFolderName); + + public string PackagesDirectory => Path.Combine(TempDirectory, LauncherFileSystemLayout.PackagesFolderName); + + public string PackageBackupsDirectory => + Path.Combine(StateDirectory, LauncherFileSystemLayout.PackageBackupsFolderName); + + public string LauncherDataFilePath => Path.Combine(StateDirectory, LauncherDataFileName); + + /// + /// Builds the image cache directory path for a mod, add-on, patch, or advertisement entry. + /// + /// + /// Thrown when is empty, whitespace, or unsafe for a path segment. + /// + public string GetModificationImagesDirectory(string modificationName) + { + string safeModificationName = LexicalPath.NormalizePathSegment(modificationName, nameof(modificationName)); + + return ResolveOwnedPath(ImagesDirectory, safeModificationName); + } + + /// + /// Builds an image cache file path for a mod, add-on, patch, or advertisement entry. + /// + /// + /// Thrown when or is empty, whitespace, or + /// unsafe for a path segment. + /// + public string GetModificationImageFilePath(string modificationName, string imageFileName) + { + string safeImageFileName = LexicalPath.NormalizePathSegment(imageFileName, nameof(imageFileName)); + + return ResolveOwnedPath(GetModificationImagesDirectory(modificationName), safeImageFileName); + } + + /// + /// Builds a package staging folder path below the launcher temporary directory. + /// + /// + /// Thrown when is empty, whitespace, or cannot be staged below the + /// launcher temporary package directory. + /// + private string GetPackageTemporaryFolderPath(string installedFolderPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(installedFolderPath); + + string installedFullPath = LexicalPath.NormalizeFullPath(installedFolderPath); + string relativePath = LexicalPath.GetRelativePath(ModsDirectory, installedFullPath); + + if (LexicalPath.RelativePathLeavesRoot(relativePath)) + { + relativePath = LexicalPath.NormalizePathSegment( + Path.GetFileName(installedFullPath), + nameof(installedFolderPath)); + } + + string temporaryPackagesRoot = PackagesDirectory; + return ResolveOwnedPath( + temporaryPackagesRoot, + relativePath, + "The installed package path cannot be staged outside the launcher temporary package folder.", + nameof(installedFolderPath)); + } + + /// + /// Builds an owned package staging path for an installed content path. + /// + public OwnedContentPath GetPackageTemporaryPath(OwnedContentPath installedPath) + { + ArgumentNullException.ThrowIfNull(installedPath); + + string temporaryFolderPath = GetPackageTemporaryFolderPath(installedPath.FullPath); + return new OwnedContentPath(PackagesDirectory, temporaryFolderPath); + } + + /// + /// Builds a durable recovery-backup path that mirrors an installed package below the canonical Mods directory. + /// + /// + /// Recovery backups live below State rather than Temp so startup cleanup cannot erase an interrupted replacement. + /// + /// + /// Thrown when is not below the canonical Mods directory. + /// + public OwnedContentPath GetPackageBackupPath(OwnedContentPath installedPath) + { + ArgumentNullException.ThrowIfNull(installedPath); + + if (!LexicalPath.IsPathBelowDirectory(installedPath.FullPath, ModsDirectory)) + { + throw new ArgumentException( + "Package recovery backups can only be created for content below the launcher Mods directory.", + nameof(installedPath)); + } + + string relativePath = LexicalPath.GetRelativePath(ModsDirectory, installedPath.FullPath); + string backupPath = ResolveOwnedPath(PackageBackupsDirectory, relativePath); + return new OwnedContentPath(PackageBackupsDirectory, backupPath); + } + + private static string ResolveOwnedPath( + string rootDirectory, + string relativePath, + string containmentFailureMessage = OwnedPathContainmentFailureMessage, + string? parameterName = null) + { + try + { + return LexicalPath.ResolveContainedPath(rootDirectory, relativePath, containmentFailureMessage); + } + catch (InvalidDataException) + { + throw new ArgumentException(containmentFailureMessage, parameterName); + } + } +} diff --git a/GenLauncherGO.Core/Startup/LauncherRuntimePathContext.cs b/GenLauncherGO.Core/Startup/LauncherRuntimePathContext.cs new file mode 100644 index 00000000..27d77736 --- /dev/null +++ b/GenLauncherGO.Core/Startup/LauncherRuntimePathContext.cs @@ -0,0 +1,52 @@ +using System; +using System.Threading; +using GenLauncherGO.Core.IO; + +namespace GenLauncherGO.Core.Startup; + +/// +/// Owns the active immutable game-path snapshot for a running standalone launcher session. +/// +/// +/// Long-running operations must read once and retain that snapshot for their full +/// lifetime. Replacing the active paths never redirects an operation that is already in progress. +/// +public sealed class LauncherRuntimePathContext +{ + private LauncherPaths _activePaths; + + public LauncherRuntimePathContext( + LauncherStoragePaths storagePaths, + LauncherPaths initialPaths) + { + StoragePaths = storagePaths ?? throw new ArgumentNullException(nameof(storagePaths)); + _activePaths = ValidateOwnedGamePaths(initialPaths); + } + + public LauncherStoragePaths StoragePaths { get; } + + public LauncherPaths ActivePaths => Volatile.Read(ref _activePaths); + + /// + /// Atomically replaces the active game-path snapshot. + /// + public void SwitchActive(LauncherPaths newPaths) + { + Interlocked.Exchange(ref _activePaths, ValidateOwnedGamePaths(newPaths)); + } + + private LauncherPaths ValidateOwnedGamePaths(LauncherPaths paths) + { + ArgumentNullException.ThrowIfNull(paths); + + string expectedDataDirectory = StoragePaths.GetGameDataDirectory(paths.Game); + if (!LexicalPath.AreEquivalent(paths.OwnedGameDataDirectory, expectedDataDirectory)) + { + throw new ArgumentException( + "The active path set must use the standalone launcher's canonical per-game data directory.", + nameof(paths)); + } + + return paths; + } +} diff --git a/GenLauncherGO.Core/Startup/LauncherStoragePaths.cs b/GenLauncherGO.Core/Startup/LauncherStoragePaths.cs new file mode 100644 index 00000000..24597424 --- /dev/null +++ b/GenLauncherGO.Core/Startup/LauncherStoragePaths.cs @@ -0,0 +1,49 @@ +using System; +using System.IO; +using GenLauncherGO.Core.IO; + +namespace GenLauncherGO.Core.Startup; + +/// +/// Defines the standalone launcher's shared storage root and isolated per-title data roots. +/// +/// +/// The executable directory is chosen by the user. All durable launcher state stays below +/// and never uses a game installation as an ownership boundary. +/// +public sealed record LauncherStoragePaths +{ + private const string PreferencesFileName = "LauncherPreferences.yaml"; + + public LauncherStoragePaths(string executableDirectory) + { + ExecutableDirectory = LexicalPath.NormalizeFullPath(executableDirectory); + } + + public string ExecutableDirectory { get; } + + public string DataDirectory => + Path.Combine(ExecutableDirectory, LauncherFileSystemLayout.LauncherDataFolderName); + + public string LogsDirectory => Path.Combine(DataDirectory, LauncherFileSystemLayout.LogsFolderName); + + public string PreferencesFilePath => Path.Combine(DataDirectory, PreferencesFileName); + + /// + /// Gets the launcher-owned data root isolated to one supported game. + /// + internal string GetGameDataDirectory(SupportedGame game) + { + return Path.Combine(DataDirectory, LauncherFileSystemLayout.GetGameDataFolderName(game)); + } + + /// + /// Creates the sole canonical path set for a validated game installation. + /// + public LauncherPaths CreateGamePaths(SupportedGame game, string validatedGameRoot) + { + ArgumentException.ThrowIfNullOrWhiteSpace(validatedGameRoot); + + return new LauncherPaths(game, validatedGameRoot, GetGameDataDirectory(game)); + } +} diff --git a/GenLauncherGO.Core/Startup/Models/GameInstallationLocation.cs b/GenLauncherGO.Core/Startup/Models/GameInstallationLocation.cs new file mode 100644 index 00000000..cf6b476f --- /dev/null +++ b/GenLauncherGO.Core/Startup/Models/GameInstallationLocation.cs @@ -0,0 +1,20 @@ +using System; +using GenLauncherGO.Core.IO; + +namespace GenLauncherGO.Core.Startup.Models; + +public sealed record GameInstallationLocation +{ + public GameInstallationLocation(SupportedGame game, string directory) + { + PerGame.EnsureSupported(game, nameof(game)); + + ArgumentException.ThrowIfNullOrWhiteSpace(directory); + Game = game; + Directory = LexicalPath.NormalizeFullPath(directory); + } + + public SupportedGame Game { get; } + + public string Directory { get; } +} diff --git a/GenLauncherGO.Core/Startup/Models/GameInstallationValidationFailure.cs b/GenLauncherGO.Core/Startup/Models/GameInstallationValidationFailure.cs new file mode 100644 index 00000000..509fbad2 --- /dev/null +++ b/GenLauncherGO.Core/Startup/Models/GameInstallationValidationFailure.cs @@ -0,0 +1,42 @@ +namespace GenLauncherGO.Core.Startup.Models; + +/// +/// Identifies why a selected game installation cannot be used. +/// +public enum GameInstallationValidationFailure +{ + /// + /// The installation is valid. + /// + None = 0, + + /// + /// No installation directory was supplied. + /// + PathMissing = 1, + + /// + /// The supplied directory does not exist. + /// + DirectoryNotFound = 2, + + /// + /// The launcher executable directory is the game installation or one of its descendants. + /// + LauncherLocationOverlapsGame = 3, + + /// + /// The path traverses a reparse point and is unsafe for launcher mutations. + /// + UnsafeFileSystemPath = 4, + + /// + /// Windows could not safely resolve or inspect the directory. + /// + PathUnavailable = 5, + + /// + /// The directory does not contain a safe built-in executable for the selected game. + /// + BuiltInExecutableNotFound = 6 +} diff --git a/GenLauncherGO.Core/Startup/Models/GameInstallationValidationResult.cs b/GenLauncherGO.Core/Startup/Models/GameInstallationValidationResult.cs new file mode 100644 index 00000000..47a9941f --- /dev/null +++ b/GenLauncherGO.Core/Startup/Models/GameInstallationValidationResult.cs @@ -0,0 +1,42 @@ +using System; + +namespace GenLauncherGO.Core.Startup.Models; + +/// +/// Describes an actionable game-installation validation outcome. +/// +public sealed record GameInstallationValidationResult +{ + private GameInstallationValidationResult( + GameInstallationValidationFailure failure, + string? canonicalPath) + { + Failure = failure; + CanonicalPath = canonicalPath; + } + + public bool IsValid => Failure == GameInstallationValidationFailure.None; + + public GameInstallationValidationFailure Failure { get; } + + public string? CanonicalPath { get; } + + public static GameInstallationValidationResult Valid(string canonicalPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(canonicalPath); + + return new GameInstallationValidationResult( + GameInstallationValidationFailure.None, + canonicalPath); + } + + public static GameInstallationValidationResult Invalid(GameInstallationValidationFailure failure) + { + if (failure == GameInstallationValidationFailure.None) + { + throw new ArgumentOutOfRangeException(nameof(failure), failure, "A validation failure is required."); + } + + return new GameInstallationValidationResult(failure, null); + } +} diff --git a/GenLauncherGO.Core/Startup/Models/LauncherInstallationsValidationResult.cs b/GenLauncherGO.Core/Startup/Models/LauncherInstallationsValidationResult.cs new file mode 100644 index 00000000..fe8a07de --- /dev/null +++ b/GenLauncherGO.Core/Startup/Models/LauncherInstallationsValidationResult.cs @@ -0,0 +1,35 @@ +using System; +using GenLauncherGO.Core.Settings.Models; + +namespace GenLauncherGO.Core.Startup.Models; + +/// +/// Describes validated per-game paths and the complete canonical installation-set outcome. +/// +public sealed record LauncherInstallationsValidationResult +{ + internal LauncherInstallationsValidationResult( + GameInstallationValidationResult generalsValidation, + GameInstallationValidationResult zeroHourValidation, + LauncherInstallations canonicalInstallations, + bool hasDuplicatePath, + bool isValid) + { + GeneralsValidation = generalsValidation ?? throw new ArgumentNullException(nameof(generalsValidation)); + ZeroHourValidation = zeroHourValidation ?? throw new ArgumentNullException(nameof(zeroHourValidation)); + CanonicalInstallations = canonicalInstallations ?? + throw new ArgumentNullException(nameof(canonicalInstallations)); + HasDuplicatePath = hasDuplicatePath; + IsValid = isValid; + } + + public GameInstallationValidationResult GeneralsValidation { get; } + + public GameInstallationValidationResult ZeroHourValidation { get; } + + public LauncherInstallations CanonicalInstallations { get; } + + public bool HasDuplicatePath { get; } + + public bool IsValid { get; } +} diff --git a/GenLauncherGO.Core/Startup/Models/LauncherRestartResult.cs b/GenLauncherGO.Core/Startup/Models/LauncherRestartResult.cs new file mode 100644 index 00000000..eac4c292 --- /dev/null +++ b/GenLauncherGO.Core/Startup/Models/LauncherRestartResult.cs @@ -0,0 +1,27 @@ +using System; + +namespace GenLauncherGO.Core.Startup.Models; + +/// +/// Reports whether launching the replacement process for an application restart succeeded. +/// +public sealed record LauncherRestartResult +{ + private LauncherRestartResult(bool succeeded, string? errorMessage) + { + Succeeded = succeeded; + ErrorMessage = errorMessage; + } + + public bool Succeeded { get; } + + public string? ErrorMessage { get; } + + public static LauncherRestartResult Success { get; } = new(true, null); + + public static LauncherRestartResult Failure(string errorMessage) + { + ArgumentException.ThrowIfNullOrWhiteSpace(errorMessage); + return new LauncherRestartResult(false, errorMessage); + } +} diff --git a/GenLauncherGO.Core/Startup/PerGame.cs b/GenLauncherGO.Core/Startup/PerGame.cs new file mode 100644 index 00000000..46be56af --- /dev/null +++ b/GenLauncherGO.Core/Startup/PerGame.cs @@ -0,0 +1,54 @@ +using System; + +namespace GenLauncherGO.Core.Startup; + +/// +/// Selects values keyed by and owns the rejection of unsupported values. +/// +/// +/// Generals and Zero Hour stay explicit named members on the types that hold them: the launcher manages exactly +/// these two games, both are named in the persisted YAML, and every consumer wants them addressable by name. What +/// the pair sites genuinely shared was the rejection of and its message, +/// which was written out at each site and now lives here once. +/// +public static class PerGame +{ + /// + /// Returns the value belonging to a supported game. + /// + /// + /// Both arguments are evaluated before the selection, so callers whose branches allocate — a record + /// with expression, for instance — should switch directly and throw in the + /// default arm instead. + /// + /// Thrown when is not supported. + public static T Select(SupportedGame game, T generals, T zeroHour, string paramName = "game") + { + return game switch + { + SupportedGame.Generals => generals, + SupportedGame.ZeroHour => zeroHour, + _ => throw Unsupported(game, paramName) + }; + } + + /// + /// Rejects a game value the launcher does not manage. + /// + /// Thrown when is not supported. + public static void EnsureSupported(SupportedGame game, string paramName = "game") + { + if (game is not SupportedGame.Generals and not SupportedGame.ZeroHour) + { + throw Unsupported(game, paramName); + } + } + + /// + /// Creates the rejection for a game value the launcher does not manage. + /// + public static ArgumentOutOfRangeException Unsupported(SupportedGame game, string paramName = "game") + { + return new ArgumentOutOfRangeException(paramName, game, "A supported game is required."); + } +} diff --git a/GenLauncherGO.Core/Startup/SupportedGame.cs b/GenLauncherGO.Core/Startup/SupportedGame.cs new file mode 100644 index 00000000..a67300e7 --- /dev/null +++ b/GenLauncherGO.Core/Startup/SupportedGame.cs @@ -0,0 +1,22 @@ +namespace GenLauncherGO.Core.Startup; + +/// +/// 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/IPackageDownloadService.cs b/GenLauncherGO.Core/Updating/Contracts/IPackageDownloadService.cs new file mode 100644 index 00000000..40318ea2 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Contracts/IPackageDownloadService.cs @@ -0,0 +1,23 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Core.Updating.Contracts; + +/// +/// Downloads and installs launcher-managed modification packages. +/// +public interface IPackageDownloadService +{ + /// + /// Downloads and installs one package, reporting progress until the returned task completes. + /// + Task DownloadAsync( + LauncherContent modification, + LauncherContentVersion version, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null); +} diff --git a/GenLauncherGO.Core/Updating/Contracts/IRemotePackageSizeResolver.cs b/GenLauncherGO.Core/Updating/Contracts/IRemotePackageSizeResolver.cs new file mode 100644 index 00000000..9075ce5f --- /dev/null +++ b/GenLauncherGO.Core/Updating/Contracts/IRemotePackageSizeResolver.cs @@ -0,0 +1,21 @@ +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Core.Updating.Contracts; + +/// +/// Resolves the total fresh-install payload size advertised by a remote launcher package source. +/// +/// +/// Implementations inspect remote metadata only and must not start or stage a package download. +/// +public interface IRemotePackageSizeResolver +{ + /// + /// Resolves the total remote payload size, or returns when the source cannot provide it. + /// + Task GetTotalBytesAsync( + LauncherContentVersion version, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Core/Updating/Models/PackageDownloadPauseController.cs b/GenLauncherGO.Core/Updating/Models/PackageDownloadPauseController.cs new file mode 100644 index 00000000..8c4042a4 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/PackageDownloadPauseController.cs @@ -0,0 +1,85 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Provides cooperative asynchronous pause and resume control for one package download. +/// +public sealed class PackageDownloadPauseController +{ + private readonly Lock _syncRoot = new(); + + private TaskCompletionSource? _resumeCompletion; + + public bool IsPaused + { + get + { + lock (_syncRoot) + { + return _resumeCompletion != null; + } + } + } + + /// + /// Pauses cooperative download work at its next checkpoint and reports whether the state changed. + /// + public bool Pause() + { + lock (_syncRoot) + { + if (_resumeCompletion != null) + { + return false; + } + + _resumeCompletion = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + return true; + } + } + + /// + /// Resumes paused download work and reports whether the state changed. + /// + public bool Resume() + { + TaskCompletionSource? resumeCompletion; + lock (_syncRoot) + { + resumeCompletion = _resumeCompletion; + _resumeCompletion = null; + } + + return resumeCompletion?.TrySetResult() == true; + } + + /// + /// Asynchronously waits until download work is resumed. + /// + public async ValueTask WaitWhilePausedAsync(CancellationToken cancellationToken) + { + Task? resumeTask; + lock (_syncRoot) + { + resumeTask = _resumeCompletion?.Task; + } + + if (resumeTask != null) + { + await resumeTask.WaitAsync(cancellationToken).ConfigureAwait(false); + } + } + + /// + /// Waits at a cooperative pause checkpoint when a controller was supplied. + /// + public static ValueTask WaitWhilePausedAsync( + PackageDownloadPauseController? controller, + CancellationToken cancellationToken) + { + return controller?.WaitWhilePausedAsync(cancellationToken) ?? ValueTask.CompletedTask; + } +} diff --git a/GenLauncherGO.Core/Updating/Models/PackageDownloadResult.cs b/GenLauncherGO.Core/Updating/Models/PackageDownloadResult.cs new file mode 100644 index 00000000..b639e8e0 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/PackageDownloadResult.cs @@ -0,0 +1,63 @@ +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Describes the single, terminal outcome of a launcher package download. +/// +public sealed record PackageDownloadResult +{ + private PackageDownloadResult( + PackageDownloadStatus status, + string message) + { + Status = status; + Message = message; + } + + public PackageDownloadStatus Status { get; } + + public string Message { get; } + + public static PackageDownloadResult Succeeded() + { + return new PackageDownloadResult( + PackageDownloadStatus.Succeeded, + string.Empty); + } + + public static PackageDownloadResult Canceled() + { + return new PackageDownloadResult( + PackageDownloadStatus.Canceled, + string.Empty); + } + + /// + /// Creates a result for a download the launcher stopped to shut down, keeping its partial content to resume. + /// + public static PackageDownloadResult Suspended() + { + return new PackageDownloadResult( + PackageDownloadStatus.Suspended, + string.Empty); + } + + /// + /// Creates an expected failure result that can normally be retried or corrected by the user. + /// + public static PackageDownloadResult RecoverableFailure(string message) + { + return new PackageDownloadResult( + PackageDownloadStatus.RecoverableFailure, + message); + } + + /// + /// Creates an unexpected failure result while preserving diagnostic detail. + /// + public static PackageDownloadResult UnexpectedFailure(string message) + { + return new PackageDownloadResult( + PackageDownloadStatus.UnexpectedFailure, + message); + } +} diff --git a/GenLauncherGO.Core/Updating/Models/PackageDownloadStatus.cs b/GenLauncherGO.Core/Updating/Models/PackageDownloadStatus.cs new file mode 100644 index 00000000..9ccfc954 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/PackageDownloadStatus.cs @@ -0,0 +1,36 @@ +namespace GenLauncherGO.Core.Updating.Models; + +/// +/// Identifies the single terminal status of a package download. +/// +public enum PackageDownloadStatus +{ + /// + /// The package was downloaded, verified, and installed. + /// + Succeeded, + + /// + /// The caller cooperatively canceled the operation before installation committed. + /// + Canceled, + + /// + /// The launcher stopped the operation to shut down and deliberately kept its partial content for resuming. + /// + /// + /// Distinct from precisely because cancellation discards partial content: the transport + /// stops the same way, but nothing is cleaned up afterwards. + /// + Suspended, + + /// + /// An expected provider, package, or local-environment condition prevented installation. + /// + RecoverableFailure, + + /// + /// An unexpected failure prevented installation and was recorded for diagnostics. + /// + UnexpectedFailure +} diff --git a/GenLauncherGO.Core/Updating/Models/PackageUpdateProgress.cs b/GenLauncherGO.Core/Updating/Models/PackageUpdateProgress.cs new file mode 100644 index 00000000..8cb10953 --- /dev/null +++ b/GenLauncherGO.Core/Updating/Models/PackageUpdateProgress.cs @@ -0,0 +1,11 @@ +using System; + +namespace GenLauncherGO.Core.Updating.Models; + +public sealed record PackageUpdateProgress( + long? TotalBytes, + long BytesRead, + double? ProgressPercentage, + string? FileName, + double? DownloadSpeedBytesPerSecond = null, + TimeSpan? EstimatedTimeRemaining = null); diff --git a/GenLauncherGO.Infrastructure/AGENTS.md b/GenLauncherGO.Infrastructure/AGENTS.md new file mode 100644 index 00000000..463b9912 --- /dev/null +++ b/GenLauncherGO.Infrastructure/AGENTS.md @@ -0,0 +1,12 @@ +# GenLauncherGO.Infrastructure Guidance + +- Keep concrete disk, network, archive, process, hashing, persistence, and logging implementations here; do not drive + Avalonia or other UI workflows. +- Bind the external YAML contract with exact transport DTOs, then map it once into normalized concepts. Preserve + accepted legacy keys, defaults, nesting, and values. +- Before traversing or mutating owned content, reuse the existing containment and path-safety primitives and fail + closed when safety cannot be proven. +- Preserve atomic writes, staging cleanup, deployment journaling, rollback, recovery, and hard-link-to-copy fallback + behavior. +- Use structured `ILogger` diagnostics around meaningful side effects and failures. Do not log credentials, tokens, + or unnecessary full user paths. diff --git a/GenLauncherGO.Infrastructure/Archives/ArchiveExtractor.cs b/GenLauncherGO.Infrastructure/Archives/ArchiveExtractor.cs new file mode 100644 index 00000000..1439f656 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Archives/ArchiveExtractor.cs @@ -0,0 +1,134 @@ +using System; +using System.IO; +using System.Threading; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Infrastructure.Archives.Contracts; +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. +/// +internal sealed class ArchiveExtractor : IArchiveExtractor +{ + private readonly ILogger _logger; + + public ArchiveExtractor(ILogger? logger = null) + { + _logger = logger ?? NullLogger.Instance; + } + + public void ExtractToDirectory( + string archiveFilePath, + string destinationDirectory, + bool convertBigFilesToGib = false, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrWhiteSpace(archiveFilePath); + ArgumentException.ThrowIfNullOrWhiteSpace(destinationDirectory); + + string destinationRoot = LexicalPath.NormalizeFullPath(destinationDirectory); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + destinationRoot, + "Archive extraction destinations"); + Directory.CreateDirectory(destinationRoot); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + destinationRoot, + "Archive extraction destinations"); + + _logger.LogDebug( + "Extracting archive {ArchiveFilePath} to {DestinationDirectory}. Convert .big files to .gib: {ConvertBigFilesToGib}", + Path.GetFileName(archiveFilePath), + Path.GetFileName(destinationRoot), + convertBigFilesToGib); + + 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, convertBigFilesToGib); + string? entryDirectory = Path.GetDirectoryName(entryPath); + if (!string.IsNullOrWhiteSpace(entryDirectory)) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + entryDirectory, + "Archive entry paths"); + Directory.CreateDirectory(entryDirectory); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + entryDirectory, + "Archive entry paths"); + } + + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + entryPath, + "Archive entry paths"); + entry.WriteToFile(entryPath, new ExtractionOptions + { + Overwrite = true, + PreserveFileTime = true + }); + + extractedEntryCount++; + } + + _logger.LogDebug( + "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. + /// + /// + /// 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, + bool convertBigFilesToGib) + { + if (string.IsNullOrWhiteSpace(entryKey)) + { + throw new InvalidDataException("Archive entry is missing a file name."); + } + + string normalizedEntryKey = entryKey.Replace('\\', Path.DirectorySeparatorChar) + .Replace('/', Path.DirectorySeparatorChar); + + string containmentFailureMessage = + $"Archive entry '{entryKey}' would extract outside the destination folder."; + string destinationPath = LexicalPath.ResolveContainedPath( + destinationRoot, + normalizedEntryKey, + containmentFailureMessage); + if (convertBigFilesToGib) + { + destinationPath = BigFileVariantPath.GetInstalledPath(destinationPath); + destinationPath = LexicalPath.ResolveContainedPath( + destinationRoot, + destinationPath, + containmentFailureMessage); + } + + return destinationPath; + } +} diff --git a/GenLauncherGO.Infrastructure/Archives/Contracts/IArchiveExtractor.cs b/GenLauncherGO.Infrastructure/Archives/Contracts/IArchiveExtractor.cs new file mode 100644 index 00000000..1638a461 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Archives/Contracts/IArchiveExtractor.cs @@ -0,0 +1,32 @@ +using System; +using System.IO; +using System.Threading; + +namespace GenLauncherGO.Infrastructure.Archives.Contracts; + +internal interface IArchiveExtractor +{ + /// + /// Extracts an archive into the specified destination directory, creating directories and overwriting existing + /// extracted files when needed. Entries may not escape the destination, and extraction can optionally rename + /// .big files to .gib. + /// + /// + /// 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, + bool convertBigFilesToGib = false, + CancellationToken cancellationToken = default); +} diff --git a/GenLauncherGO.Infrastructure/Common/BigFileVariantPath.cs b/GenLauncherGO.Infrastructure/Common/BigFileVariantPath.cs new file mode 100644 index 00000000..fafb46b1 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Common/BigFileVariantPath.cs @@ -0,0 +1,101 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Common; + +/// +/// Resolves manifest, installed, deployment, and in-progress path variants for package .big files. +/// +internal static class BigFileVariantPath +{ + /// + /// Returns the installed path, converting a .big path to its .gib variant. + /// + public static string GetInstalledPath(string filePath) + { + return IsBigFilePath(filePath) + ? GetGibVariantPath(filePath) + : filePath; + } + + /// + /// Returns the deployment path, converting an installed .gib path to its game-facing .big variant. + /// + public static string GetDeploymentPath(string filePath) + { + return IsGibFilePath(filePath) + ? Path.ChangeExtension(filePath, LauncherContentFileTypes.BigExtension) + : filePath; + } + + /// + /// Returns the same-base .gib variant for a requested path. + /// + public static string GetGibVariantPath(string filePath) + { + return Path.ChangeExtension(filePath, LauncherContentFileTypes.GibExtension); + } + + /// + /// Returns the existing downloaded path, preferring the requested path and then the converted .gib path. + /// + public static string GetExistingDownloadedPath(string destinationFilePath) + { + if (File.Exists(destinationFilePath)) + { + return destinationFilePath; + } + + string gibFilePath = GetGibVariantPath(destinationFilePath); + return File.Exists(gibFilePath) ? gibFilePath : string.Empty; + } + + /// + /// Converts a downloaded .big file to its installed .gib path. + /// + public static void ConvertBigFileToGib(string destinationFilePath) + { + if (!IsBigFilePath(destinationFilePath)) + { + return; + } + + string gibFilePath = GetGibVariantPath(destinationFilePath); + 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. + /// + public static void PrepareBigFileResumePath(string destinationFilePath) + { + if (!IsBigFilePath(destinationFilePath)) + { + return; + } + + string gibFilePath = GetGibVariantPath(destinationFilePath); + if (!File.Exists(gibFilePath) || File.Exists(destinationFilePath)) + { + return; + } + + File.Move(gibFilePath, destinationFilePath); + } + + private static bool IsBigFilePath(string filePath) + { + return string.Equals(Path.GetExtension(filePath), LauncherContentFileTypes.BigExtension, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsGibFilePath(string filePath) + { + return string.Equals(Path.GetExtension(filePath), LauncherContentFileTypes.GibExtension, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/GenLauncherGO.Infrastructure/Common/FileSystemPathSafety.cs b/GenLauncherGO.Infrastructure/Common/FileSystemPathSafety.cs new file mode 100644 index 00000000..f2fa2907 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Common/FileSystemPathSafety.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GenLauncherGO.Core.IO; + +namespace GenLauncherGO.Infrastructure.Common; + +/// +/// Provides shared filesystem path-safety checks for infrastructure services. +/// +/// +/// Every check names the paths it guards with a plural noun phrase — "Deployment journal paths", "Package +/// staging paths" — and builds its rejection message from that phrase. Callers pass the subject rather than +/// finished sentences so one wording change reaches every safety failure in the launcher. +/// +internal static class FileSystemPathSafety +{ + /// + /// Resolves a candidate path and verifies that it stays within an owned root without traversing existing links. + /// + /// The launcher-owned directory the candidate must remain inside. + /// The path to resolve and verify. + /// The plural noun phrase naming the guarded paths. + /// + /// The container named by the containment failure, such as "the deployment directory". + /// + public static string ResolveOwnedSubpath( + string ownedRoot, + string candidatePath, + string pathSubject, + string ownerDescription) + { + string normalizedRoot = LexicalPath.NormalizeFullPath(ownedRoot); + string normalizedCandidate = LexicalPath.NormalizeFullPath(candidatePath); + if (!LexicalPath.IsPathInDirectory(normalizedCandidate, normalizedRoot)) + { + throw new InvalidDataException($"{pathSubject} must stay inside {ownerDescription}."); + } + + EnsureExistingPathChainHasNoReparsePoints(normalizedRoot, pathSubject); + EnsureExistingPathChainHasNoReparsePoints(normalizedCandidate, pathSubject); + + return normalizedCandidate; + } + + /// + /// Rejects paths whose existing filesystem chain contains a reparse point. + /// + /// The path whose existing ancestors are inspected. + /// The plural noun phrase naming the guarded paths. + public static void EnsureExistingPathChainHasNoReparsePoints(string path, string pathSubject) + { + if (ExistingPathChainContainsReparsePoint(path, pathSubject)) + { + throw new InvalidDataException(CreateLinkedPathMessage(pathSubject)); + } + } + + /// + /// Rejects a directory tree whose root or child entries contain a reparse point. + /// + /// The root of the tree to inspect. + /// The plural noun phrase naming the guarded paths. + public static void EnsureDirectoryTreeHasNoReparsePoints(string directoryPath, string pathSubject) + { + string rootPath = NormalizeAndValidateTreeRoot(directoryPath, pathSubject); + InspectDirectoryChildren(rootPath, pathSubject, null); + } + + /// + /// Returns every file below a directory after rejecting reparse points anywhere in the tree. + /// + /// The root of the tree to enumerate. + /// The plural noun phrase naming the guarded paths. + public static IReadOnlyList GetDirectoryFilesWithNoReparsePoints( + string directoryPath, + string pathSubject) + { + string rootPath = NormalizeAndValidateTreeRoot(directoryPath, pathSubject); + var files = new List(); + InspectDirectoryChildren(rootPath, pathSubject, files); + return files; + } + + /// + /// Determines whether an existing path chain contains a reparse point. + /// + /// The path whose existing ancestors are inspected. + /// The plural noun phrase naming the guarded paths. + public static bool ExistingPathChainContainsReparsePoint(string path, string pathSubject) + { + string fullPath = LexicalPath.NormalizeFullPath(path); + string root = Path.GetPathRoot(fullPath) + ?? throw new InvalidDataException(CreateUnrootedPathMessage(pathSubject)); + string relativePath = LexicalPath.GetRelativePath(root, fullPath); + if (relativePath == ".") + { + return false; + } + + string currentPath = root; + string[] segments = relativePath.Split( + [Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar], + StringSplitOptions.RemoveEmptyEntries); + foreach (string segment in segments) + { + currentPath = Path.Combine(currentPath, segment); + if (!TryGetAttributes(currentPath, out FileAttributes attributes)) + { + return false; + } + + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + return true; + } + } + + return false; + } + + /// + /// Determines whether a filesystem entry is a reparse point. + /// + public static bool IsReparsePoint(string path) + { + return (File.GetAttributes(path) & FileAttributes.ReparsePoint) != 0; + } + + /// + /// Creates recursive enumeration options that never traverse reparse points. + /// + public static EnumerationOptions CreateRecursiveNoLinksOptions() + { + return new EnumerationOptions + { + AttributesToSkip = FileAttributes.ReparsePoint, + IgnoreInaccessible = false, + RecurseSubdirectories = true, + ReturnSpecialDirectories = false + }; + } + + private static string CreateUnrootedPathMessage(string pathSubject) + { + return $"{pathSubject} must be rooted."; + } + + private static string CreateLinkedPathMessage(string pathSubject) + { + return $"{pathSubject} must not contain reparse points."; + } + + private static string NormalizeAndValidateTreeRoot(string directoryPath, string pathSubject) + { + string rootPath = LexicalPath.NormalizeFullPath(directoryPath); + if (IsReparsePoint(rootPath)) + { + throw new InvalidDataException(CreateLinkedPathMessage(pathSubject)); + } + + return rootPath; + } + + internal static bool TryGetAttributes(string path, out FileAttributes attributes) + { + try + { + attributes = File.GetAttributes(path); + return true; + } + catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException) + { + attributes = default; + return false; + } + } + + private static void InspectDirectoryChildren( + string directoryPath, + string pathSubject, + ICollection? files) + { + foreach (string entryPath in Directory.EnumerateFileSystemEntries(directoryPath)) + { + FileAttributes attributes = File.GetAttributes(entryPath); + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidDataException(CreateLinkedPathMessage(pathSubject)); + } + + if ((attributes & FileAttributes.Directory) != 0) + { + InspectDirectoryChildren(entryPath, pathSubject, files); + } + else + { + files?.Add(entryPath); + } + } + } +} diff --git a/GenLauncherGO.Infrastructure/Common/ManifestPathResolver.cs b/GenLauncherGO.Infrastructure/Common/ManifestPathResolver.cs new file mode 100644 index 00000000..452e6c61 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Common/ManifestPathResolver.cs @@ -0,0 +1,132 @@ +using System; +using System.IO; +using GenLauncherGO.Core.IO; + +namespace GenLauncherGO.Infrastructure.Common; + +/// +/// Defines the relative-path grammar shared by remote package manifests and durable deployment manifests. +/// +internal static class ManifestPathResolver +{ + /// + /// Resolves a remote manifest file name to a full path under the specified root directory. + /// + /// + /// Thrown when the root directory or manifest file name is empty, rooted, drive-qualified, or contains a current + /// or parent directory segment. + /// + /// + /// Thrown when the resolved path would leave . + /// + public static string ResolvePath(string rootDirectory, string manifestFileName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(rootDirectory); + ArgumentException.ThrowIfNullOrWhiteSpace(manifestFileName); + + return LexicalPath.ResolveContainedPath( + rootDirectory, + NormalizeRelativePath(manifestFileName), + $"Manifest file '{manifestFileName}' would resolve outside the package directory."); + } + + /// + /// Resolves the installed path for a remote manifest file, including the canonical .big-to-.gib + /// conversion. + /// + public static string ResolveInstalledPath(string rootDirectory, string manifestFileName) + { + return BigFileVariantPath.GetInstalledPath(ResolvePath(rootDirectory, manifestFileName)); + } + + /// + /// Normalizes a remote manifest path to the current platform directory separator after validation. + /// + /// + /// Thrown when the path is rooted, drive-qualified, empty, or contains a current or parent directory segment. + /// + public static string NormalizeRelativePath(string manifestFileName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(manifestFileName); + + return NormalizeRelativePathCore( + manifestFileName.Trim(), + "Manifest file paths", + message => new ArgumentException(message, nameof(manifestFileName))); + } + + /// + /// Normalizes a remote manifest path to slash separators for manifest index lookups. + /// + public static string NormalizeForManifestIndex(string manifestFileName) + { + return LexicalPath.NormalizeRelativePath(NormalizeRelativePath(manifestFileName)); + } + + /// + /// Normalizes the installed relative path used for manifest lookups, including the canonical + /// .big-to-.gib conversion. + /// + public static string NormalizeInstalledPathForManifestIndex(string manifestFileName) + { + return BigFileVariantPath.GetInstalledPath(NormalizeForManifestIndex(manifestFileName)); + } + + /// + /// Normalizes a deployment manifest path to slash separators while preserving its durable-state error contract. + /// + /// Thrown when the path is empty or whitespace. + /// + /// Thrown when the path is rooted, drive-qualified, or contains a current or parent directory segment. + /// + public static string NormalizeForDeploymentManifest(string relativePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); + + return LexicalPath.NormalizeRelativePath( + NormalizeRelativePathCore( + relativePath, + "Deployment manifest paths", + message => new InvalidDataException(message))); + } + + /// + /// Applies the shared relative-path grammar, letting each caller name its paths and choose its failure type. + /// + /// + /// Remote manifest names are caller input and fail as ; deployment manifest + /// paths are read back from durable journal state, where the same malformed value means corrupt data. Only + /// the exception type and the subject differ, so the grammar itself lives here once. + /// + private static string NormalizeRelativePathCore( + string relativePath, + string pathSubject, + Func createValidationException) + { + if (Path.IsPathRooted(relativePath) || + relativePath.Contains(':', StringComparison.Ordinal)) + { + throw createValidationException($"{pathSubject} must be relative."); + } + + string[] segments = relativePath.Split( + ['/', '\\'], + StringSplitOptions.RemoveEmptyEntries); + if (segments.Length == 0) + { + throw createValidationException($"{pathSubject} must include a file name."); + } + + foreach (string segment in segments) + { + if (string.Equals(segment, ".", StringComparison.Ordinal) || + string.Equals(segment, "..", StringComparison.Ordinal)) + { + throw createValidationException( + $"{pathSubject} must not contain parent directory segments."); + } + } + + return Path.Combine(segments); + } +} diff --git a/GenLauncherGO.Infrastructure/Common/OwnedDirectoryTree.cs b/GenLauncherGO.Infrastructure/Common/OwnedDirectoryTree.cs new file mode 100644 index 00000000..1b14fb62 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Common/OwnedDirectoryTree.cs @@ -0,0 +1,430 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Common; + +/// +/// Mutates explicitly launcher-owned directory trees without traversing reparse points. +/// +internal static class OwnedDirectoryTree +{ + /// + /// Creates an owned directory when it does not exist and rejects an existing linked entry. + /// + public static string EnsureExists(string ownedRoot, string directoryPath) + { + string normalizedPath = ResolveOwnedDirectoryPath(ownedRoot, directoryPath); + EnsureSafeAncestors(ownedRoot, normalizedPath); + if (FileSystemPathSafety.TryGetAttributes(normalizedPath, out FileAttributes attributes)) + { + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidDataException("Owned directories must not be reparse points."); + } + + if ((attributes & FileAttributes.Directory) == 0) + { + throw new IOException("An owned directory path is occupied by a file."); + } + + return normalizedPath; + } + + Directory.CreateDirectory(normalizedPath); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + normalizedPath, + "Owned directory paths"); + return normalizedPath; + } + + /// + /// Ensures an owned path is a real directory, replacing a linked leaf with an empty real directory. + /// + public static string EnsureRealDirectory(string ownedRoot, string directoryPath) + { + string normalizedPath = ResolveOwnedDirectoryPath(ownedRoot, directoryPath); + EnsureSafeAncestors(ownedRoot, normalizedPath); + + if (!FileSystemPathSafety.TryGetAttributes(normalizedPath, out FileAttributes attributes)) + { + Directory.CreateDirectory(normalizedPath); + return normalizedPath; + } + + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + DeleteEntryWithoutFollowing(normalizedPath, attributes); + Directory.CreateDirectory(normalizedPath); + return normalizedPath; + } + + if ((attributes & FileAttributes.Directory) == 0) + { + throw new IOException("An owned directory path is occupied by a file."); + } + + return normalizedPath; + } + + /// + /// Deletes an owned directory tree when it exists. + /// + /// + /// Nested links are deleted as entries without traversing them, so their targets remain untouched. + /// + public static bool DeleteIfExists(OwnedContentPath ownedPath) + { + ArgumentNullException.ThrowIfNull(ownedPath); + + return DeleteIfExists(ownedPath.OwnerRoot, ownedPath.FullPath); + } + + /// + /// Deletes a directory tree below an explicit owned root when it exists. + /// + /// + /// Nested links are deleted as entries without traversing them, so their targets remain untouched. + /// + public static bool DeleteIfExists(string ownedRoot, string directoryPath) + { + string normalizedPath = ResolveOwnedDirectoryPath(ownedRoot, directoryPath); + EnsureSafeAncestors(ownedRoot, normalizedPath); + if (!FileSystemPathSafety.TryGetAttributes(normalizedPath, out FileAttributes attributes)) + { + return false; + } + + DeleteEntryWithoutFollowing(normalizedPath, attributes); + return true; + } + + /// + /// Creates an owned directory when necessary and deletes all of its existing child entries. + /// + /// + /// When the directory itself is a link, the link is deleted and replaced by a real directory. Nested links are + /// deleted as entries without traversing them. + /// + public static string PrepareEmpty(string ownedRoot, string directoryPath) + { + string normalizedPath = ResolveOwnedDirectoryPath(ownedRoot, directoryPath); + EnsureSafeAncestors(ownedRoot, normalizedPath); + + if (FileSystemPathSafety.TryGetAttributes(normalizedPath, out FileAttributes attributes)) + { + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + DeleteEntryWithoutFollowing(normalizedPath, attributes); + Directory.CreateDirectory(normalizedPath); + return normalizedPath; + } + + if ((attributes & FileAttributes.Directory) == 0) + { + throw new IOException("An owned directory path is occupied by a file."); + } + + DeleteDirectoryChildren(normalizedPath); + return normalizedPath; + } + + Directory.CreateDirectory(normalizedPath); + return normalizedPath; + } + + /// + /// Deletes empty parent directories between a child path and its exclusive ownership boundary. + /// + public static IReadOnlyList DeleteEmptyParents(string ownedRoot, string childPath) + { + string normalizedRoot = LexicalPath.NormalizeFullPath(ownedRoot); + string normalizedChild = LexicalPath.NormalizeFullPath(childPath); + if (!LexicalPath.IsPathInDirectory(normalizedChild, normalizedRoot)) + { + throw new InvalidOperationException("Refusing to prune directories outside the owned root."); + } + + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + normalizedRoot, + "Owned directory roots"); + + var deletedDirectories = new List(); + DirectoryInfo? current = Directory.GetParent(normalizedChild); + while (current is not null && + !LexicalPath.AreEquivalent(current.FullName, normalizedRoot)) + { + string currentPath = current.FullName; + DirectoryInfo? parent = current.Parent; + if (!LexicalPath.IsPathInDirectory(currentPath, normalizedRoot)) + { + break; + } + + if (!FileSystemPathSafety.TryGetAttributes(currentPath, out FileAttributes attributes)) + { + current = parent; + continue; + } + + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidDataException("Owned directory parents must not contain reparse points."); + } + + if ((attributes & FileAttributes.Directory) == 0 || + Directory.EnumerateFileSystemEntries(currentPath).Any()) + { + break; + } + + Directory.Delete(currentPath, false); + deletedDirectories.Add(currentPath); + current = parent; + } + + return deletedDirectories; + } + + /// + /// Deletes empty parent directories and their exclusive ownership boundary when the complete chain is empty. + /// + public static IReadOnlyList DeleteEmptyParentsIncludingRoot(string ownedRoot, string childPath) + { + string normalizedRoot = LexicalPath.NormalizeFullPath(ownedRoot); + var deletedDirectories = DeleteEmptyParents(normalizedRoot, childPath).ToList(); + if (!FileSystemPathSafety.TryGetAttributes(normalizedRoot, out FileAttributes attributes) || + (attributes & FileAttributes.Directory) == 0) + { + return deletedDirectories; + } + + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidDataException("Owned directory roots must not be reparse points."); + } + + if (!Directory.EnumerateFileSystemEntries(normalizedRoot).Any()) + { + Directory.Delete(normalizedRoot, false); + deletedDirectories.Add(normalizedRoot); + } + + return deletedDirectories; + } + + /// + /// Recursively deletes empty real directories below an owned content path without traversing or deleting links. + /// + public static bool DeleteEmptyDirectories(OwnedContentPath ownedPath) + { + ArgumentNullException.ThrowIfNull(ownedPath); + + EnsureSafeAncestors(ownedPath.OwnerRoot, ownedPath.FullPath); + return DeleteEmptyDirectoriesCore(ownedPath.FullPath); + } + + /// + /// Recursively deletes empty real directories, including an exclusive launcher-owned root when it becomes empty. + /// + public static bool DeleteEmptyDirectories(string ownedRoot) + { + string normalizedRoot = LexicalPath.NormalizeFullPath(ownedRoot); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + normalizedRoot, + "Owned directory roots"); + return DeleteEmptyDirectoriesCore(normalizedRoot); + } + + /// + /// Deletes all reparse-point entries below an owned real directory without traversing their targets. + /// + public static IReadOnlyList DeleteReparsePoints(OwnedContentPath ownedPath) + { + ArgumentNullException.ThrowIfNull(ownedPath); + + EnsureSafeAncestors(ownedPath.OwnerRoot, ownedPath.FullPath); + var deletedPaths = new List(); + DeleteReparsePointsCore(ownedPath.FullPath, deletedPaths); + return deletedPaths; + } + + private static string ResolveOwnedDirectoryPath(string ownedRoot, string directoryPath) + { + return new OwnedContentPath(ownedRoot, directoryPath).FullPath; + } + + private static void EnsureSafeAncestors(string ownedRoot, string directoryPath) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + ownedRoot, + "Owned directory roots"); + + string? parentDirectory = Path.GetDirectoryName(directoryPath); + if (!string.IsNullOrWhiteSpace(parentDirectory)) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + parentDirectory, + "Owned directory paths"); + } + } + + /// + /// Empties an owned directory except for one child directory that must survive. + /// + /// + /// Used where a scratch directory also holds state a later session still needs, so the two cannot simply be + /// cleared together. + /// + public static string PrepareEmptyExcept(string ownedRoot, string directoryPath, string preservedChildPath) + { + string normalizedPath = ResolveOwnedDirectoryPath(ownedRoot, directoryPath); + EnsureSafeAncestors(ownedRoot, normalizedPath); + + if (!FileSystemPathSafety.TryGetAttributes(normalizedPath, out FileAttributes attributes) || + (attributes & FileAttributes.ReparsePoint) != 0 || + (attributes & FileAttributes.Directory) == 0) + { + return PrepareEmpty(ownedRoot, directoryPath); + } + + string normalizedPreservedPath = ResolveOwnedDirectoryPath(ownedRoot, preservedChildPath); + DeleteDirectoryChildren(normalizedPath, normalizedPreservedPath); + return normalizedPath; + } + + private static void DeleteDirectoryChildren(string directoryPath, string? preservedPath = null) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + directoryPath, + "Owned directory paths"); + + foreach (string entryPath in Directory.EnumerateFileSystemEntries(directoryPath).ToList()) + { + if (!FileSystemPathSafety.TryGetAttributes(entryPath, out FileAttributes attributes)) + { + continue; + } + + if (preservedPath != null && + LexicalPath.AreEquivalent(entryPath, preservedPath)) + { + continue; + } + + DeleteEntryWithoutFollowing(entryPath, attributes); + } + } + + private static bool DeleteEmptyDirectoriesCore(string directoryPath) + { + if (!FileSystemPathSafety.TryGetAttributes(directoryPath, out FileAttributes attributes) || + (attributes & FileAttributes.ReparsePoint) != 0 || + (attributes & FileAttributes.Directory) == 0) + { + return false; + } + + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + directoryPath, + "Owned directory paths"); + + foreach (string entryPath in Directory.EnumerateDirectories(directoryPath).ToList()) + { + if (!FileSystemPathSafety.TryGetAttributes(entryPath, out FileAttributes childAttributes) || + (childAttributes & FileAttributes.ReparsePoint) != 0) + { + continue; + } + + DeleteEmptyDirectoriesCore(entryPath); + } + + if (Directory.EnumerateFileSystemEntries(directoryPath).Any()) + { + return false; + } + + Directory.Delete(directoryPath, false); + return true; + } + + private static void DeleteReparsePointsCore(string directoryPath, List deletedPaths) + { + if (!FileSystemPathSafety.TryGetAttributes(directoryPath, out FileAttributes directoryAttributes) || + (directoryAttributes & FileAttributes.ReparsePoint) != 0 || + (directoryAttributes & FileAttributes.Directory) == 0) + { + throw new InvalidDataException("Owned directory traversal requires a real directory."); + } + + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + directoryPath, + "Owned directory paths"); + + foreach (string entryPath in Directory.EnumerateFileSystemEntries(directoryPath).ToList()) + { + if (!FileSystemPathSafety.TryGetAttributes(entryPath, out FileAttributes attributes)) + { + continue; + } + + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + DeleteLinkEntry(entryPath, attributes); + deletedPaths.Add(entryPath); + continue; + } + + if ((attributes & FileAttributes.Directory) != 0) + { + DeleteReparsePointsCore(entryPath, deletedPaths); + } + } + } + + private static void DeleteEntryWithoutFollowing(string entryPath, FileAttributes observedAttributes) + { + if ((observedAttributes & FileAttributes.ReparsePoint) != 0) + { + DeleteLinkEntry(entryPath, observedAttributes); + return; + } + + if ((observedAttributes & FileAttributes.Directory) != 0) + { + if (!FileSystemPathSafety.TryGetAttributes(entryPath, out FileAttributes currentAttributes)) + { + return; + } + + if ((currentAttributes & FileAttributes.ReparsePoint) != 0) + { + DeleteLinkEntry(entryPath, currentAttributes); + return; + } + + DeleteDirectoryChildren(entryPath); + Directory.Delete(entryPath, false); + return; + } + + File.Delete(entryPath); + } + + private static void DeleteLinkEntry(string entryPath, FileAttributes attributes) + { + if ((attributes & FileAttributes.Directory) != 0) + { + Directory.Delete(entryPath, false); + } + else + { + File.Delete(entryPath); + } + } + +} diff --git a/GenLauncherGO.Infrastructure/Common/PhysicalDirectoryPath.cs b/GenLauncherGO.Infrastructure/Common/PhysicalDirectoryPath.cs new file mode 100644 index 00000000..d2c7aeae --- /dev/null +++ b/GenLauncherGO.Infrastructure/Common/PhysicalDirectoryPath.cs @@ -0,0 +1,211 @@ +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Runtime.InteropServices.ComTypes; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace GenLauncherGO.Infrastructure.Common; + +/// +/// Identifies an existing file-system object independently of aliases in its path spelling. +/// +internal readonly record struct PhysicalFileSystemIdentity(uint VolumeSerialNumber, ulong FileIndex); + +/// +/// Resolves existing Windows directories through handles for security-sensitive comparisons and recovery metadata. +/// +internal static class PhysicalDirectoryPath +{ + private const uint FileReadAttributes = 0x0080; + private const uint FileShareRead = 0x00000001; + private const uint FileShareWrite = 0x00000002; + private const uint FileShareDelete = 0x00000004; + private const uint OpenExisting = 3; + private const uint FileFlagBackupSemantics = 0x02000000; + private const uint FileNameNormalized = 0x0; + private const uint VolumeNameDos = 0x0; + private const string ExtendedPathPrefix = @"\\?\"; + private const string ExtendedUncPrefix = @"\\?\UNC\"; + + /// + /// Returns the canonical path observed through a handle to an existing directory. + /// + public static string ResolveExisting(string path) + { + using SafeFileHandle handle = OpenDirectory(path); + var buffer = new StringBuilder(512); + uint requiredLength = GetFinalPathNameByHandle( + handle, + buffer, + (uint)buffer.Capacity, + FileNameNormalized | VolumeNameDos); + if (requiredLength == 0) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + if (requiredLength >= buffer.Capacity) + { + buffer.EnsureCapacity(checked((int)requiredLength + 1)); + requiredLength = GetFinalPathNameByHandle( + handle, + buffer, + (uint)buffer.Capacity, + FileNameNormalized | VolumeNameDos); + if (requiredLength == 0 || requiredLength >= buffer.Capacity) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + } + + return NormalizeHandlePath(buffer.ToString()); + } + + /// + /// Returns the stable volume and file-index identity of an existing directory. + /// + public static PhysicalFileSystemIdentity GetIdentity(string path) + { + using SafeFileHandle handle = OpenDirectory(path); + return GetIdentity(handle); + } + + /// + /// Returns the stable volume and file-index identity of an existing file. + /// + public static PhysicalFileSystemIdentity GetFileIdentity(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + string fullPath = Path.GetFullPath(path); + if (!File.Exists(fullPath)) + { + throw new FileNotFoundException("The file does not exist.", fullPath); + } + + using SafeFileHandle handle = OpenExistingPathHandle(fullPath, 0); + return GetIdentity(handle); + } + + private static PhysicalFileSystemIdentity GetIdentity(SafeFileHandle handle) + { + if (!GetFileInformationByHandle(handle, out ByHandleFileInformation information)) + { + throw new Win32Exception(Marshal.GetLastWin32Error()); + } + + ulong fileIndex = ((ulong)information.FileIndexHigh << 32) | information.FileIndexLow; + return new PhysicalFileSystemIdentity(information.VolumeSerialNumber, fileIndex); + } + + private static SafeFileHandle OpenDirectory(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + string fullPath = Path.GetFullPath(path); + if (!Directory.Exists(fullPath)) + { + throw new DirectoryNotFoundException("The directory does not exist."); + } + + return OpenExistingPathHandle(fullPath, FileFlagBackupSemantics); + } + + private static SafeFileHandle OpenExistingPathHandle(string fullPath, uint flagsAndAttributes) + { + SafeFileHandle handle = CreateFile( + ToExtendedLengthPath(fullPath), + FileReadAttributes, + FileShareRead | FileShareWrite | FileShareDelete, + IntPtr.Zero, + OpenExisting, + flagsAndAttributes, + IntPtr.Zero); + if (handle.IsInvalid) + { + int error = Marshal.GetLastWin32Error(); + handle.Dispose(); + throw new Win32Exception(error); + } + + return handle; + } + + /// + /// Returns the absolute Win32 extended-length form of a local or UNC path. + /// + public static string ToExtendedLengthPath(string path) + { + ArgumentException.ThrowIfNullOrWhiteSpace(path); + + string fullPath = Path.GetFullPath(path); + if (fullPath.StartsWith(ExtendedPathPrefix, StringComparison.Ordinal)) + { + return fullPath; + } + + return fullPath.StartsWith(@"\\", StringComparison.Ordinal) + ? ExtendedUncPrefix + fullPath[2..] + : ExtendedPathPrefix + fullPath; + } + + private static string NormalizeHandlePath(string path) + { + string normalizedPath; + if (path.StartsWith(ExtendedUncPrefix, StringComparison.OrdinalIgnoreCase)) + { + normalizedPath = @"\\" + path[ExtendedUncPrefix.Length..]; + } + else if (path.StartsWith(ExtendedPathPrefix, StringComparison.OrdinalIgnoreCase)) + { + normalizedPath = path[ExtendedPathPrefix.Length..]; + } + else + { + normalizedPath = path; + } + + return Path.TrimEndingDirectorySeparator(Path.GetFullPath(normalizedPath)); + } + + [DllImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern SafeFileHandle CreateFile( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [DllImport("kernel32.dll", EntryPoint = "GetFinalPathNameByHandleW", SetLastError = true, + CharSet = CharSet.Unicode)] + private static extern uint GetFinalPathNameByHandle( + SafeFileHandle file, + StringBuilder filePath, + uint filePathLength, + uint flags); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetFileInformationByHandle( + SafeFileHandle file, + out ByHandleFileInformation fileInformation); + + [StructLayout(LayoutKind.Sequential)] + private struct ByHandleFileInformation + { + public uint FileAttributes; + public FILETIME CreationTime; + public FILETIME LastAccessTime; + public FILETIME LastWriteTime; + public uint VolumeSerialNumber; + public uint FileSizeHigh; + public uint FileSizeLow; + public uint NumberOfLinks; + public uint FileIndexHigh; + public uint FileIndexLow; + } +} diff --git a/GenLauncherGO.Infrastructure/GenLauncherGO.Infrastructure.csproj b/GenLauncherGO.Infrastructure/GenLauncherGO.Infrastructure.csproj new file mode 100644 index 00000000..5967758d --- /dev/null +++ b/GenLauncherGO.Infrastructure/GenLauncherGO.Infrastructure.csproj @@ -0,0 +1,21 @@ + + + net10.0-windows + + + + + + + + + + + + + + + + + + diff --git a/GenLauncherGO.Infrastructure/InfrastructureServiceCollectionExtensions.cs b/GenLauncherGO.Infrastructure/InfrastructureServiceCollectionExtensions.cs new file mode 100644 index 00000000..409d404c --- /dev/null +++ b/GenLauncherGO.Infrastructure/InfrastructureServiceCollectionExtensions.cs @@ -0,0 +1,80 @@ +using System; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Remote; +using GenLauncherGO.Core.Shell.Contracts; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Infrastructure.Archives; +using GenLauncherGO.Infrastructure.Archives.Contracts; +using GenLauncherGO.Infrastructure.Integrity.Contracts; +using GenLauncherGO.Infrastructure.Integrity.Services; +using GenLauncherGO.Infrastructure.Launching.Contracts; +using GenLauncherGO.Infrastructure.Launching.Services; +using GenLauncherGO.Infrastructure.Launching.Support; +using GenLauncherGO.Infrastructure.Mods.Contracts; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Infrastructure.Persistence.Services; +using GenLauncherGO.Infrastructure.Remote; +using GenLauncherGO.Infrastructure.Remote.Contracts; +using GenLauncherGO.Infrastructure.Shell.Services; +using GenLauncherGO.Infrastructure.Updating.Clients; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace GenLauncherGO.Infrastructure; + +/// +/// Registers the launcher runtime's Infrastructure services after storage and logging bootstrap is complete. +/// +public static class InfrastructureServiceCollectionExtensions +{ + public static IServiceCollection AddGenLauncherGoInfrastructure(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.TryAddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + services.AddSingleton(); + + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + 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/Integrity/Contracts/IContentIntegrityService.cs b/GenLauncherGO.Infrastructure/Integrity/Contracts/IContentIntegrityService.cs new file mode 100644 index 00000000..d57acd81 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Integrity/Contracts/IContentIntegrityService.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Infrastructure.Integrity.Contracts; + +/// +/// Verifies, snapshots, and cleans launcher-owned content. +/// +internal interface IContentIntegrityService +{ + /// + /// Verifies all targets against trusted snapshots owned by the supplied immutable game namespace. + /// + Task VerifyAsync( + LauncherPaths paths, + IReadOnlyList targets, + CancellationToken cancellationToken); + + /// + /// Captures a trusted snapshot in the supplied immutable game namespace only when a target currently contains + /// exactly the expected safe file set. + /// + /// + /// when a snapshot was captured; otherwise, when the current + /// file set contains extras, missing files, empty directories, unsafe links, or unreadable entries. + /// + Task CaptureSnapshotIfMatchesExpectedFileSetAsync( + LauncherPaths paths, + ContentIntegrityTarget target, + IReadOnlySet expectedRelativePaths, + CancellationToken cancellationToken); + + /// + /// Replaces a target's trusted snapshot with its current safe directory contents. + /// + Task CaptureSnapshotAsync( + LauncherPaths paths, + ContentIntegrityTarget target, + CancellationToken cancellationToken); + + /// + /// Deletes managed entries explicitly listed for deletion in a verification report, resolving them only within + /// the verified targets. + /// + Task ApplyCleanupAsync( + ContentIntegrityReport report, + IReadOnlyList targets, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Integrity/Services/FileSystemContentIntegrityService.cs b/GenLauncherGO.Infrastructure/Integrity/Services/FileSystemContentIntegrityService.cs new file mode 100644 index 00000000..2bb651fd --- /dev/null +++ b/GenLauncherGO.Infrastructure/Integrity/Services/FileSystemContentIntegrityService.cs @@ -0,0 +1,481 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Integrity.Contracts; +using GenLauncherGO.Infrastructure.Integrity.Support; +using GenLauncherGO.Infrastructure.Persistence.Services; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Integrity.Services; + +/// +/// Verifies launcher-owned content with SHA-256 snapshots and applies confirmed managed-content cleanup. +/// +internal sealed class FileSystemContentIntegrityService : IContentIntegrityService +{ + private readonly IAtomicFileWriter _atomicFileWriter; + private readonly ILogger _logger; + + public FileSystemContentIntegrityService( + IAtomicFileWriter atomicFileWriter, + ILogger logger) + { + _atomicFileWriter = atomicFileWriter ?? throw new ArgumentNullException(nameof(atomicFileWriter)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task VerifyAsync( + LauncherPaths paths, + IReadOnlyList targets, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(targets); + ContentIntegritySnapshotStore snapshotStore = CreateSnapshotStore(paths); + long verificationStartedTimestamp = Stopwatch.GetTimestamp(); + + 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 = []; + 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(CreateVerificationError(target, 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(CreateVerificationError(target, 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(CreateVerificationError(target, exception.Message)); + } + } + + if (targets.Count > 0) + { + _logger.LogInformation( + "Completed content integrity verification for {TargetCount} target(s) in {ElapsedMilliseconds} ms; issues: {IssueCount}.", + targets.Count, + (long)Stopwatch.GetElapsedTime(verificationStartedTimestamp).TotalMilliseconds, + issues.Count); + } + + return new ContentIntegrityReport(issues); + } + + public async Task CaptureSnapshotIfMatchesExpectedFileSetAsync( + LauncherPaths paths, + ContentIntegrityTarget target, + IReadOnlySet expectedRelativePaths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(target); + ArgumentNullException.ThrowIfNull(expectedRelativePaths); + ContentIntegritySnapshotStore snapshotStore = CreateSnapshotStore(paths); + + 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( + LauncherPaths paths, + ContentIntegrityTarget target, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(target); + ContentIntegritySnapshotStore snapshotStore = CreateSnapshotStore(paths); + + 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); + var deleteIssues = report.Issues + .Where(issue => issue.Action == IntegrityIssueAction.Delete) + .ToList(); + + _logger.LogInformation( + "Applying content integrity cleanup for {DeleteIssueCount} delete issue(s).", + deleteIssues.Count); + + foreach (ContentIntegrityIssue issue in deleteIssues) + { + cancellationToken.ThrowIfCancellationRequested(); + if (!targetIndex.TryGetValue(issue.TargetId, out ContentIntegrityTarget? target)) + { + throw new InvalidDataException("The cleanup report references an unknown integrity target."); + } + + string path = ContentIntegrityPath.ResolveRelativePath(target.RootDirectory, issue.RelativePath); + string? parentDirectory = Path.GetDirectoryName(path); + if (!string.IsNullOrWhiteSpace(parentDirectory)) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + parentDirectory, + "Integrity cleanup paths"); + } + + DeleteEntry(path, issue, target); + } + + var changedTargetIds = deleteIssues + .Select(issue => issue.TargetId) + .ToHashSet(StringComparer.Ordinal); + foreach (ContentIntegrityTarget target in targets.Where(target => + changedTargetIds.Contains(target.Id))) + { + DeleteUnexpectedEmptyDirectories(target, cancellationToken); + } + + _logger.LogInformation( + "Completed content integrity cleanup for {DeleteIssueCount} delete issue(s).", + deleteIssues.Count); + return Task.CompletedTask; + } + + private ContentIntegritySnapshotStore CreateSnapshotStore(LauncherPaths paths) + { + return new ContentIntegritySnapshotStore( + paths.IntegrityDirectory, + _atomicFileWriter, + _logger); + } + + 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(CreateManagedOrManualChangeIssue( + target, + IntegrityIssueKind.MissingFile, + expected.RelativePath, + expectedSizeBytes: expected.Size)); + continue; + } + + if (current.Size != expected.Size || + !string.Equals(current.Sha256, expected.Sha256, StringComparison.OrdinalIgnoreCase)) + { + issues.Add(CreateManagedOrManualChangeIssue( + target, + IntegrityIssueKind.ModifiedFile, + expected.RelativePath, + expectedSizeBytes: expected.Size)); + } + } + + foreach (ContentIntegrityScannedFile current in scan.Files.Values) + { + if (!expectedFiles.ContainsKey(current.RelativePath)) + { + issues.Add(CreateManagedOrManualChangeIssue( + target, + IntegrityIssueKind.UnexpectedFile, + current.RelativePath)); + } + } + + HashSet expectedEmptyDirectories = + new(snapshot.EmptyDirectories, StringComparer.OrdinalIgnoreCase); + foreach (string directory in scan.EmptyDirectories) + { + if (!expectedEmptyDirectories.Contains(directory)) + { + issues.Add(CreateManagedOrManualChangeIssue( + target, + IntegrityIssueKind.EmptyDirectory, + directory)); + } + } + + AddScanSafetyIssues(target, scan, issues); + } + + private static void AddScanSafetyIssues( + ContentIntegrityTarget target, + ContentIntegrityScanResult scan, + List issues) + { + foreach (string unsafeLink in scan.UnsafeLinks) + { + issues.Add(CreateIssue( + target, + IntegrityIssueKind.UnsafeLink, + target.SourceKind.IsManagedRemote() + ? IntegrityIssueAction.Delete + : IntegrityIssueAction.Block, + unsafeLink)); + } + + foreach (ContentIntegrityScanError error in scan.Errors) + { + issues.Add(CreateIssue( + target, + IntegrityIssueKind.VerificationError, + IntegrityIssueAction.Block, + error.RelativePath, + error.Message)); + } + } + + 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, + LexicalPath.NormalizeRelativePath(relativePath), + message, + expectedSizeBytes); + } + + private static ContentIntegrityIssue CreateVerificationError( + ContentIntegrityTarget target, + string message) + { + return CreateIssue( + target, + IntegrityIssueKind.VerificationError, + IntegrityIssueAction.Block, + ".", + message); + } + + private static ContentIntegrityIssue CreateManagedOrManualChangeIssue( + ContentIntegrityTarget target, + IntegrityIssueKind kind, + string relativePath, + long? expectedSizeBytes = null) + { + return CreateIssue( + target, + kind, + GetManagedOrManualAction(target.SourceKind, kind), + relativePath, + expectedSizeBytes: expectedSizeBytes); + } + + private static IntegrityIssueAction GetUntrackedAction(ContentSourceKind sourceKind) + { + return sourceKind switch + { + ContentSourceKind.ManagedS3 => IntegrityIssueAction.Repair, + ContentSourceKind.ManagedSingleFile => IntegrityIssueAction.Redownload, + ContentSourceKind.Manual => IntegrityIssueAction.Absorb, + _ => IntegrityIssueAction.TrustAsManual + }; + } + + private static IntegrityIssueAction GetManagedOrManualAction( + ContentSourceKind sourceKind, + IntegrityIssueKind issueKind) + { + bool isUnexpectedEntry = issueKind is + IntegrityIssueKind.UnexpectedFile or IntegrityIssueKind.EmptyDirectory; + + return sourceKind switch + { + ContentSourceKind.Manual => IntegrityIssueAction.Absorb, + ContentSourceKind.ManagedSingleFile => isUnexpectedEntry + ? IntegrityIssueAction.Delete + : IntegrityIssueAction.Redownload, + ContentSourceKind.ManagedS3 => isUnexpectedEntry + ? IntegrityIssueAction.Delete + : IntegrityIssueAction.Repair, + _ => IntegrityIssueAction.TrustAsManual + }; + } + + /// + /// Deletes one confirmed managed-content entry without following links. + /// + private void DeleteEntry( + string path, + ContentIntegrityIssue issue, + ContentIntegrityTarget target) + { + FileAttributes attributes; + try + { + attributes = File.GetAttributes(path); + } + catch (Exception exception) when (exception is FileNotFoundException or DirectoryNotFoundException) + { + return; + } + + if ((attributes & FileAttributes.Directory) != 0) + { + Directory.Delete(path, false); + } + else + { + File.Delete(path); + } + + _logger.LogDebug( + "Deleted confirmed unexpected integrity entry {RelativePath} from {TargetName}.", + issue.RelativePath, + target.DisplayName); + } + + 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 = ContentIntegrityPath.GetRelativePath(target.RootDirectory, directory); + if (ContentIntegrityPath.IsIgnored(target, relativePath) || + FileSystemPathSafety.IsReparsePoint(directory) || + Directory.EnumerateFileSystemEntries(directory).Any()) + { + continue; + } + + Directory.Delete(directory); + _logger.LogDebug( + "Deleted empty integrity cleanup directory {RelativePath} from {TargetName}.", + relativePath, + target.DisplayName); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityPath.cs b/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityPath.cs new file mode 100644 index 00000000..b415c2a3 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityPath.cs @@ -0,0 +1,44 @@ +using System.IO; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.IO; + +namespace GenLauncherGO.Infrastructure.Integrity.Support; + +/// +/// Applies integrity-target containment and ignore policy to canonical lexical paths. +/// +internal static class ContentIntegrityPath +{ + /// + /// Gets a normalized relative path after proving that it does not leave the verified target. + /// + public static string GetRelativePath(string root, string path) + { + string relativePath = LexicalPath.GetRelativePath(root, path); + if (LexicalPath.RelativePathLeavesRoot(relativePath)) + { + throw new InvalidDataException("A scanned entry resolved outside the verified target."); + } + + return relativePath; + } + + /// + /// Resolves an integrity issue path after proving that it remains in the verified target. + /// + public static string ResolveRelativePath(string root, string relativePath) + { + return LexicalPath.ResolveContainedPath( + root, + relativePath, + "An integrity issue path resolved outside its target root."); + } + + /// + /// Determines whether a target-relative path belongs to preserved inactive content. + /// + public static bool IsIgnored(ContentIntegrityTarget target, string relativePath) + { + return target.IgnoredRelativePaths.Contains(LexicalPath.NormalizeRelativePath(relativePath)); + } +} diff --git a/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityScanner.cs b/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityScanner.cs new file mode 100644 index 00000000..d4980f03 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegrityScanner.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Concurrent; +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.Core.IO; +using GenLauncherGO.Infrastructure.Common; + +namespace GenLauncherGO.Infrastructure.Integrity.Support; + +/// +/// Scans content integrity targets without following reparse points. +/// +internal static class ContentIntegrityScanner +{ + private const int MaxConcurrentFileHashes = 4; + + /// + /// Scans one target without following reparse points. + /// + public static async Task ScanAsync( + ContentIntegrityTarget target, + CancellationToken cancellationToken) + { + Dictionary files = new(StringComparer.OrdinalIgnoreCase); + List emptyDirectories = []; + List unsafeLinks = []; + List errors = []; + List<(string Path, string RelativePath)> fileCandidates = []; + + string root = LexicalPath.NormalizeFullPath(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 = ContentIntegrityPath.GetRelativePath(root, directory); + + try + { + bool isRootDirectory = LexicalPath.AreEquivalent(directory, root); + if (!isRootDirectory && + FileSystemPathSafety.IsReparsePoint(directory)) + { + unsafeLinks.Add(directoryRelativePath); + continue; + } + + var entries = Directory.EnumerateFileSystemEntries(directory).ToList(); + if (entries.Count == 0 && + !isRootDirectory && + !ContentIntegrityPath.IsIgnored(target, directoryRelativePath)) + { + emptyDirectories.Add(directoryRelativePath); + } + + foreach (string entry in entries) + { + cancellationToken.ThrowIfCancellationRequested(); + string relativePath = ContentIntegrityPath.GetRelativePath(root, entry); + FileAttributes attributes = File.GetAttributes(entry); + if (ContentIntegrityPath.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; + } + + fileCandidates.Add((entry, relativePath)); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + errors.Add(new ContentIntegrityScanError(directoryRelativePath, exception.Message)); + } + } + + var scannedFiles = new ConcurrentDictionary( + StringComparer.OrdinalIgnoreCase); + var scanErrors = new ConcurrentBag(); + await Parallel.ForEachAsync( + fileCandidates, + new ParallelOptions + { + CancellationToken = cancellationToken, + MaxDegreeOfParallelism = Math.Min(Environment.ProcessorCount, MaxConcurrentFileHashes) + }, + async (candidate, token) => + { + try + { + ContentIntegrityScannedFile scannedFile = await ScanFileAsync( + candidate.Path, + candidate.RelativePath, + token) + .ConfigureAwait(false); + scannedFiles[candidate.RelativePath] = scannedFile; + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + scanErrors.Add(new ContentIntegrityScanError(candidate.RelativePath, exception.Message)); + } + }).ConfigureAwait(false); + + foreach ((string relativePath, ContentIntegrityScannedFile scannedFile) in scannedFiles) + { + files[relativePath] = scannedFile; + } + + errors.AddRange(scanErrors); + + return new ContentIntegrityScanResult(files, emptyDirectories, unsafeLinks, errors); + } + + /// + /// Determines whether a completed scan exactly matches an 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(LexicalPath.NormalizeRelativePath) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + return normalizedExpectedPaths.SetEquals(scan.Files.Keys); + } + + private static async Task ScanFileAsync( + string filePath, + string relativePath, + CancellationToken cancellationToken) + { + await using FileStream stream = new( + filePath, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + 1024 * 1024, + FileOptions.Asynchronous | FileOptions.SequentialScan); + long length = stream.Length; + byte[] hash = await SHA256.HashDataAsync(stream, cancellationToken).ConfigureAwait(false); + return new ContentIntegrityScannedFile(relativePath, length, Convert.ToHexString(hash)); + } +} + +internal sealed record ContentIntegrityScannedFile(string RelativePath, long Size, string Sha256); + +internal sealed record ContentIntegrityScanError(string RelativePath, string Message); + +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..50ff9ec1 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Integrity/Support/ContentIntegritySnapshotStore.cs @@ -0,0 +1,147 @@ +using System; +using System.Collections.Generic; +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 GenLauncherGO.Core.IO; +using GenLauncherGO.Infrastructure.Persistence.Services; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Integrity.Support; + +/// +/// Persists content integrity snapshots for verified targets. +/// +internal sealed class ContentIntegritySnapshotStore +{ + private const int SnapshotSchemaVersion = 1; + + private static readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true + }; + + private readonly IAtomicFileWriter _atomicFileWriter; + + private readonly ILogger _logger; + + private readonly string _snapshotDirectory; + + public ContentIntegritySnapshotStore( + string snapshotDirectory, + IAtomicFileWriter atomicFileWriter, + ILogger logger) + { + ArgumentException.ThrowIfNullOrWhiteSpace(snapshotDirectory); + _snapshotDirectory = LexicalPath.NormalizeFullPath(snapshotDirectory); + _atomicFileWriter = atomicFileWriter ?? throw new ArgumentNullException(nameof(atomicFileWriter)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Writes a trusted snapshot for a previously completed safe scan. + /// + public async Task WriteSnapshotAsync( + ContentIntegrityTarget target, + ContentIntegrityScanResult scan, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + 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()); + + string snapshotPath = GetSnapshotPath(target.Id); + await _atomicFileWriter.WriteAsync( + snapshotPath, + (stream, token) => JsonSerializer.SerializeAsync(stream, snapshot, _jsonOptions, token), + cancellationToken) + .ConfigureAwait(false); + _logger.LogInformation( + "Captured SHA-256 integrity snapshot for {TargetName}; files: {FileCount}.", + target.DisplayName, + snapshot.Files.Count); + } + + /// + /// Reads a persisted snapshot, returning when none 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; + } + + 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. +/// +internal sealed record ContentIntegritySnapshotFileEntry(string RelativePath, long Size, string Sha256); + +/// +/// Describes a persisted trusted content snapshot. +/// +internal sealed record ContentIntegritySnapshotDocument( + int SchemaVersion, + string TargetId, + ContentSourceKind SourceKind, + IReadOnlyList Files, + IReadOnlyList EmptyDirectories); diff --git a/GenLauncherGO.Infrastructure/Launching/Contracts/ILaunchContentIntegrityTargetBuilder.cs b/GenLauncherGO.Infrastructure/Launching/Contracts/ILaunchContentIntegrityTargetBuilder.cs new file mode 100644 index 00000000..c490c913 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Contracts/ILaunchContentIntegrityTargetBuilder.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Launching.Models; + +namespace GenLauncherGO.Infrastructure.Launching.Contracts; + +internal interface ILaunchContentIntegrityTargetBuilder +{ + IReadOnlyList BuildTargets( + LaunchContentIntegrityTargetRequest request); +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/DeploymentFileTransaction.cs b/GenLauncherGO.Infrastructure/Launching/Services/DeploymentFileTransaction.cs new file mode 100644 index 00000000..47df13a3 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/DeploymentFileTransaction.cs @@ -0,0 +1,734 @@ +using System; +using System.Buffers; +using System.IO; +using System.Security.Cryptography; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Launching.Support; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Launching.Services; + +/// +/// Owns verified backup, staging, deployment, and restoration for individual game-directory files. +/// +internal sealed class DeploymentFileTransaction +{ + private const int FileBufferSize = 1024 * 128; + + private readonly IHardLinkCreator _hardLinkCreator; + private readonly ILogger _logger; + + public DeploymentFileTransaction(IHardLinkCreator hardLinkCreator, ILogger logger) + { + _hardLinkCreator = hardLinkCreator ?? throw new ArgumentNullException(nameof(hardLinkCreator)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Journals intent and completion around committing a verified launcher-owned backup before removing a target. + /// + public DeploymentBackupDocument BackupTargetFile( + DeploymentStatePaths deploymentPaths, + FileStream journal, + 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", + "the deployment directory"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath) ?? deploymentPaths.BackupDirectory); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + Path.GetDirectoryName(backupPath) ?? deploymentPaths.BackupDirectory, + "Deployment backup paths"); + bool canMoveOriginal = _hardLinkCreator.ArePathsOnSameVolume(targetPath, backupPath); + string backupStagingPath = canMoveOriginal + ? string.Empty + : backupPath + $".partial-{Guid.NewGuid():N}"; + string backupStagingRelativePath = canMoveOriginal + ? string.Empty + : DeploymentPathResolver.ToRelativeManifestPath( + deploymentPaths.DeploymentDirectory, + backupStagingPath); + DeploymentStateStore.AppendJournalDurably( + journal, + DeploymentJournalRecord.FileBackupStarted( + targetRelativePath, + backupRelativePath, + backupStagingRelativePath)); + + DeploymentFileFingerprint backupFingerprint; + try + { + if (canMoveOriginal) + { + File.Move(targetPath, backupPath, false); + backupFingerprint = ComputeFileFingerprint(backupPath); + } + else + { + backupFingerprint = CopyFileWithMetadataAndFlush(targetPath, backupStagingPath); + File.Move(backupStagingPath, backupPath, false); + } + } + catch + { + if (!string.IsNullOrWhiteSpace(backupStagingPath)) + { + DeleteFileClearingReadOnly(backupStagingPath); + } + + throw; + } + + var backedUpRecord = DeploymentJournalRecord.FileBackedUp( + targetRelativePath, + backupRelativePath, + backupFingerprint, + backupStagingRelativePath); + if (canMoveOriginal) + { + DeploymentStateStore.AppendJournal(journal, backedUpRecord); + } + else + { + // The completed backup becomes the write-ahead intent for deleting the original on another volume. + DeploymentStateStore.AppendJournalDurably(journal, backedUpRecord); + } + + if (!canMoveOriginal) + { + EnsureFingerprintMatches( + targetPath, + backupFingerprint, + "The original game file changed while its backup was being committed."); + DeleteFileClearingReadOnly(targetPath); + } + + return new DeploymentBackupDocument( + backupRelativePath, + backupFingerprint, + backupStagingRelativePath); + } + + /// + /// Deploys one file with a hard link first and verified copy fallback. + /// + public (DeploymentMethod Method, DeploymentFileFingerprint Fingerprint, string? FileIdentity) DeployFile( + FileStream source, + string sourcePath, + string targetPath, + string stagingPath, + DeploymentFileFingerprint expectedFingerprint) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + sourcePath, + "Deployment source paths"); + + if (File.Exists(targetPath)) + { + throw new IOException("A deployment target appeared after its original content was backed up."); + } + + DeploymentMethod method; + string? fileIdentity = null; + try + { + bool sourceIsReadOnly = (File.GetAttributes(sourcePath) & FileAttributes.ReadOnly) != 0; + if (!sourceIsReadOnly && + _hardLinkCreator.ArePathsOnSameVolume(sourcePath, stagingPath) && + _hardLinkCreator.TryCreateHardLink(stagingPath, sourcePath)) + { + method = DeploymentMethod.HardLink; + fileIdentity = DeploymentStateStore.GetFileIdentity(sourcePath); + if (!string.Equals( + fileIdentity, + DeploymentStateStore.GetFileIdentity(stagingPath), + StringComparison.Ordinal)) + { + throw new IOException("The staged hard link did not reference its source file."); + } + } + else + { + if (File.Exists(stagingPath)) + { + throw new IOException("A deployment staging path was occupied unexpectedly."); + } + + DeploymentFileFingerprint copiedFingerprint = CopyFileAndFlush(source, stagingPath); + File.SetLastWriteTimeUtc(stagingPath, File.GetLastWriteTimeUtc(sourcePath)); + if (copiedFingerprint != expectedFingerprint) + { + throw new IOException("The source file changed while it was staged for deployment."); + } + + method = DeploymentMethod.Copy; + _logger.LogDebug( + "Hard-link deployment was unavailable for {FileName}; used a verified file copy.", + Path.GetFileName(targetPath)); + } + + File.Move(stagingPath, targetPath, false); + return (method, expectedFingerprint, fileIdentity); + } + catch + { + DeleteOwnedStagingFileIfExpected(stagingPath, expectedFingerprint, false); + throw; + } + } + + /// + /// Replays one persisted file transaction to remove deployed content or restore its original backup. + /// + public void RestoreFile( + LauncherPaths paths, + DeploymentStatePaths deploymentPaths, + DeploymentManifestDocument manifest, + DeploymentFileDocument file, + FileStream journal) + { + string targetPath = DeploymentPathResolver.ResolveGamePath(paths, file.TargetRelativePath); + EnsureSafeGameMutationPath(paths, targetPath); + CleanupGameStagingFile( + paths, + file.StagingRelativePath, + file.DeployedFingerprint, + false); + CleanupGameStagingFile( + paths, + file.RestoreStagingRelativePath, + file.BackupFingerprint, + true); + CleanupBackupStagingFile(deploymentPaths, file.BackupStagingRelativePath); + + if (string.IsNullOrWhiteSpace(file.BackupRelativePath)) + { + DeleteDeployedFileWithoutBackup(file, targetPath, journal); + return; + } + + string backupPath = DeploymentPathResolver.ResolveDeploymentStatePath( + deploymentPaths.DeploymentDirectory, + file.BackupRelativePath); + backupPath = FileSystemPathSafety.ResolveOwnedSubpath( + deploymentPaths.DeploymentDirectory, + backupPath, + "Deployment backup paths", + "the deployment directory"); + (DeploymentFileFingerprint Fingerprint, bool Exists) backup = + ResolveBackupFingerprint(file, backupPath, targetPath); + if (!backup.Exists) + { + return; + } + + RestoreBackup( + paths, + manifest.DeploymentId, + file, + targetPath, + backupPath, + backup.Fingerprint, + journal); + } + + public static DeploymentFileFingerprint ComputeFileFingerprint(FileStream stream) + { + stream.Position = 0; + return new DeploymentFileFingerprint(stream.Length, Convert.ToHexString(SHA256.HashData(stream))); + } + + public static FileStream OpenDeploymentSource(string path) + { + return new FileStream( + path, + FileMode.Open, + FileAccess.Read, + FileShare.Read, + FileBufferSize, + FileOptions.SequentialScan); + } + + /// + /// Verifies that a game-directory mutation target stays in the game folder and does not cross child reparse points. + /// + public static void EnsureSafeGameMutationPath(LauncherPaths paths, string targetPath) + { + _ = FileSystemPathSafety.ResolveOwnedSubpath( + paths.GameDirectory, + targetPath, + "Deployment target paths", + "the game directory"); + } + + private void DeleteDeployedFileWithoutBackup( + DeploymentFileDocument file, + string targetPath, + FileStream journal) + { + if (!File.Exists(targetPath)) + { + return; + } + + RequireExpectedTarget( + targetPath, + file, + "A deployed game file was modified after launch preparation; cleanup left it untouched."); + DeleteDeployedTarget(targetPath, file); + DeploymentStateStore.AppendJournal( + journal, + DeploymentJournalRecord.FileCleanupDeleted(file.TargetRelativePath)); + } + + private static (DeploymentFileFingerprint Fingerprint, bool Exists) ResolveBackupFingerprint( + DeploymentFileDocument file, + string backupPath, + string targetPath) + { + DeploymentFileFingerprint? backupFingerprint = file.BackupFingerprint; + bool backupExists = File.Exists(backupPath); + if (backupExists) + { + DeploymentFileFingerprint observedBackupFingerprint = ComputeFileFingerprint(backupPath); + if (backupFingerprint is not null && observedBackupFingerprint != backupFingerprint) + { + throw new InvalidDataException( + "A launcher-owned deployment backup changed unexpectedly; the game file was left untouched."); + } + + backupFingerprint = observedBackupFingerprint; + } + + if (backupFingerprint is null) + { + throw new InvalidDataException( + "Deployment recovery cannot verify the original game file because its backup fingerprint is missing."); + } + + if (!backupExists && + (!File.Exists(targetPath) || ComputeFileFingerprint(targetPath) != backupFingerprint)) + { + throw new InvalidDataException( + "The original game-file backup is missing and the target is not already restored."); + } + + return (backupFingerprint, backupExists); + } + + private void RestoreBackup( + LauncherPaths paths, + string deploymentId, + DeploymentFileDocument file, + string targetPath, + string backupPath, + DeploymentFileFingerprint backupFingerprint, + FileStream journal) + { + EnsureSafeGameMutationPath(paths, targetPath); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath) ?? paths.GameDirectory); + EnsureSafeGameMutationPath(paths, targetPath); + string candidateRestoreStagingPath = CreateSiblingStagingPath( + targetPath, + deploymentId, + "restore"); + bool canMoveOriginal = _hardLinkCreator.ArePathsOnSameVolume( + backupPath, + candidateRestoreStagingPath); + string restoreStagingPath = canMoveOriginal + ? string.Empty + : candidateRestoreStagingPath; + string restoreStagingRelativePath = canMoveOriginal + ? string.Empty + : DeploymentPathResolver.ToRelativeManifestPath( + paths.GameDirectory, + restoreStagingPath); + DeploymentStateStore.AppendJournalDurably( + journal, + DeploymentJournalRecord.FileCleanupRestoreStarted( + file.TargetRelativePath, + file.BackupRelativePath!, + restoreStagingRelativePath)); + + try + { + if (File.Exists(targetPath)) + { + RequireExpectedRestoreTarget( + targetPath, + file, + backupFingerprint, + "A game file was modified after launch preparation; its original backup was preserved."); + DeleteDeployedTarget(targetPath, file); + } + + if (canMoveOriginal) + { + File.Move(backupPath, targetPath, false); + } + else + { + StageVerifiedFile( + backupPath, + restoreStagingPath, + backupFingerprint); + File.Move(restoreStagingPath, targetPath, false); + DeleteFileClearingReadOnly(backupPath); + } + + DeploymentStateStore.AppendJournal( + journal, + DeploymentJournalRecord.FileCleanupRestored( + file.TargetRelativePath, + file.BackupRelativePath!)); + } + catch + { + if (!string.IsNullOrWhiteSpace(restoreStagingPath)) + { + DeleteOwnedStagingFileIfExpected( + restoreStagingPath, + backupFingerprint, + true); + } + + throw; + } + } + + private static string CreateBackupRelativePath(string deploymentId, string targetRelativePath) + { + return LexicalPath.NormalizeRelativePath(Path.Combine( + DeploymentStateStore.BackupsDirectoryName, + deploymentId, + targetRelativePath)); + } + + private static void StageVerifiedFile( + string sourcePath, + string stagingPath, + DeploymentFileFingerprint expectedFingerprint) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + sourcePath, + "Deployment source paths"); + + if (File.Exists(stagingPath)) + { + throw new IOException("A deployment staging path was occupied unexpectedly."); + } + + DeploymentFileFingerprint copiedFingerprint = CopyFileWithMetadataAndFlush(sourcePath, stagingPath); + if (copiedFingerprint != expectedFingerprint) + { + throw new IOException("The source file changed while it was copied into the game directory."); + } + } + + private static DeploymentFileFingerprint CopyFileAndFlush(FileStream source, string destinationPath) + { + source.Position = 0; + byte[] buffer = ArrayPool.Shared.Rent(FileBufferSize); + try + { + using FileStream destination = new( + destinationPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + FileBufferSize, + FileOptions.SequentialScan); + int bytesRead; + while ((bytesRead = source.Read(buffer, 0, buffer.Length)) != 0) + { + destination.Write(buffer, 0, bytesRead); + } + + destination.Flush(true); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + return ComputeFileFingerprint(destinationPath); + } + + /// + /// Uses the Windows file-copy path so alternate streams and security data are retained, then restores + /// timestamps and mutable attributes that the platform copy operation does not preserve exactly. + /// + private static DeploymentFileFingerprint CopyFileWithMetadataAndFlush( + string sourcePath, + string destinationPath) + { + FileAttributes sourceAttributes = File.GetAttributes(sourcePath); + DateTime creationTimeUtc = File.GetCreationTimeUtc(sourcePath); + DateTime lastAccessTimeUtc = File.GetLastAccessTimeUtc(sourcePath); + DateTime lastWriteTimeUtc = File.GetLastWriteTimeUtc(sourcePath); + + try + { + File.Copy(sourcePath, destinationPath, false); + FileAttributes destinationAttributes = File.GetAttributes(destinationPath); + if ((destinationAttributes & FileAttributes.ReadOnly) != 0) + { + File.SetAttributes(destinationPath, destinationAttributes & ~FileAttributes.ReadOnly); + } + + using (FileStream destination = new( + destinationPath, + FileMode.Open, + FileAccess.ReadWrite, + FileShare.Read)) + { + destination.Flush(true); + } + + DeploymentFileFingerprint fingerprint = ComputeFileFingerprint(destinationPath); + + File.SetCreationTimeUtc(destinationPath, creationTimeUtc); + File.SetLastAccessTimeUtc(destinationPath, lastAccessTimeUtc); + File.SetLastWriteTimeUtc(destinationPath, lastWriteTimeUtc); + + const FileAttributes MutableAttributes = + FileAttributes.ReadOnly | + FileAttributes.Hidden | + FileAttributes.System | + FileAttributes.Archive | + FileAttributes.Temporary | + FileAttributes.Offline | + FileAttributes.NotContentIndexed; + destinationAttributes = File.GetAttributes(destinationPath); + File.SetAttributes( + destinationPath, + (destinationAttributes & ~MutableAttributes) | (sourceAttributes & MutableAttributes)); + return fingerprint; + } + catch + { + DeleteFileClearingReadOnly(destinationPath); + throw; + } + } + + private static DeploymentFileFingerprint ComputeFileFingerprint(string path) + { + using FileStream stream = OpenDeploymentSource(path); + return ComputeFileFingerprint(stream); + } + + private static void EnsureFingerprintMatches( + string path, + DeploymentFileFingerprint expectedFingerprint, + string errorMessage) + { + if (ComputeFileFingerprint(path) != expectedFingerprint) + { + throw new InvalidDataException(errorMessage); + } + } + + private static void RequireExpectedTarget( + string targetPath, + DeploymentFileDocument file, + string conflictMessage) + { + if (IsExpectedHardLink(targetPath, file)) + { + return; + } + + bool hasRecordedHardLinkIdentity = file.Method == DeploymentMethod.HardLink && + !string.IsNullOrWhiteSpace(file.DeployedFileIdentity); + if (hasRecordedHardLinkIdentity || + file.DeployedFingerprint is null || + ComputeFileFingerprint(targetPath) != file.DeployedFingerprint) + { + throw new InvalidDataException(conflictMessage); + } + } + + private static void RequireExpectedRestoreTarget( + string targetPath, + DeploymentFileDocument file, + DeploymentFileFingerprint backupFingerprint, + string conflictMessage) + { + if (IsExpectedHardLink(targetPath, file)) + { + return; + } + + DeploymentFileFingerprint targetFingerprint = ComputeFileFingerprint(targetPath); + if (targetFingerprint == backupFingerprint) + { + return; + } + + bool hasRecordedHardLinkIdentity = file.Method == DeploymentMethod.HardLink && + !string.IsNullOrWhiteSpace(file.DeployedFileIdentity); + if (hasRecordedHardLinkIdentity || + file.DeployedFingerprint is null || + targetFingerprint != file.DeployedFingerprint) + { + throw new InvalidDataException(conflictMessage); + } + } + + private static bool IsExpectedHardLink(string targetPath, DeploymentFileDocument file) + { + return file.Method == DeploymentMethod.HardLink && + !string.IsNullOrWhiteSpace(file.DeployedFileIdentity) && + string.Equals( + DeploymentStateStore.GetFileIdentity(targetPath), + file.DeployedFileIdentity, + StringComparison.Ordinal); + } + + private static void DeleteFileClearingReadOnly(string path) + { + if (!File.Exists(path)) + { + return; + } + + FileAttributes attributes = File.GetAttributes(path); + if ((attributes & FileAttributes.ReadOnly) != 0) + { + File.SetAttributes(path, attributes & ~FileAttributes.ReadOnly); + } + + File.Delete(path); + } + + private static void DeleteDeployedTarget(string targetPath, DeploymentFileDocument file) + { + if (file.Method == DeploymentMethod.HardLink) + { + File.Delete(targetPath); + return; + } + + DeleteFileClearingReadOnly(targetPath); + } + + private void CleanupGameStagingFile( + LauncherPaths paths, + string? stagingRelativePath, + DeploymentFileFingerprint? expectedFingerprint, + bool clearReadOnly) + { + if (string.IsNullOrWhiteSpace(stagingRelativePath)) + { + return; + } + + string stagingPath = DeploymentPathResolver.ResolveGamePath(paths, stagingRelativePath); + EnsureSafeGameMutationPath(paths, stagingPath); + if (!File.Exists(stagingPath)) + { + return; + } + + if (expectedFingerprint is not null && + ComputeFileFingerprint(stagingPath) != expectedFingerprint) + { + _logger.LogWarning( + "Removed incomplete transaction staging file {FileName} during deployment recovery.", + Path.GetFileName(stagingPath)); + } + + if (clearReadOnly) + { + DeleteFileClearingReadOnly(stagingPath); + } + else + { + File.Delete(stagingPath); + } + } + + private static void CleanupBackupStagingFile( + DeploymentStatePaths deploymentPaths, + string? stagingRelativePath) + { + if (string.IsNullOrWhiteSpace(stagingRelativePath)) + { + return; + } + + string stagingPath = DeploymentPathResolver.ResolveDeploymentStatePath( + deploymentPaths.DeploymentDirectory, + stagingRelativePath); + stagingPath = FileSystemPathSafety.ResolveOwnedSubpath( + deploymentPaths.DeploymentDirectory, + stagingPath, + "Deployment backup staging paths", + "the deployment directory"); + if (File.Exists(stagingPath)) + { + DeleteFileClearingReadOnly(stagingPath); + } + } + + private void DeleteOwnedStagingFileIfExpected( + string stagingPath, + DeploymentFileFingerprint expectedFingerprint, + bool clearReadOnly) + { + if (!File.Exists(stagingPath)) + { + return; + } + + try + { + if (ComputeFileFingerprint(stagingPath) == expectedFingerprint) + { + if (clearReadOnly) + { + DeleteFileClearingReadOnly(stagingPath); + } + else + { + File.Delete(stagingPath); + } + + return; + } + + _logger.LogWarning( + "Left deployment staging file {FileName} untouched because its contents changed unexpectedly.", + Path.GetFileName(stagingPath)); + } + catch (IOException exception) + { + _logger.LogWarning( + exception, + "Could not inspect deployment staging file {FileName}.", + Path.GetFileName(stagingPath)); + } + } + + public static string CreateSiblingStagingPath(string targetPath, string deploymentId, string operation) + { + string directory = Path.GetDirectoryName(targetPath) + ?? throw new InvalidOperationException( + "Deployment target paths must have a parent directory."); + string fileName = Path.GetFileName(targetPath); + return Path.Combine( + directory, + $".{fileName}.GenLauncherGO-{operation}-{deploymentId}-{Guid.NewGuid():N}.tmp"); + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/DeploymentLaunchPreparationService.cs b/GenLauncherGO.Infrastructure/Launching/Services/DeploymentLaunchPreparationService.cs new file mode 100644 index 00000000..43f8e418 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/DeploymentLaunchPreparationService.cs @@ -0,0 +1,95 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Mods.Services; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Launching.Support; + +namespace GenLauncherGO.Infrastructure.Launching.Services; + +/// +/// Orchestrates launch preparation by translating selected content into deployment packages. +/// +internal 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" + ]; + + private readonly FileSystemDeploymentService _deploymentEngine; + + public DeploymentLaunchPreparationService(FileSystemDeploymentService deploymentEngine) + { + _deploymentEngine = deploymentEngine ?? throw new ArgumentNullException(nameof(deploymentEngine)); + } + + public bool Prepare( + LaunchPreparationRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + IReadOnlyList packages = CreateDeploymentPackages(request); + IReadOnlyList disabledTargetRelativePaths = request.DisableBaseGameScriptFiles + ? _baseGameScriptRelativePaths + : Array.Empty(); + DeploymentResult result = _deploymentEngine.Prepare( + request.Paths, + packages, + disabledTargetRelativePaths, + cancellationToken); + return result.Succeeded; + } + + public bool Cleanup( + LauncherPaths paths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(paths); + + DeploymentResult result = _deploymentEngine.Cleanup(paths, cancellationToken); + return result.Succeeded; + } + + public bool Recover( + LauncherPaths paths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(paths); + + DeploymentResult result = _deploymentEngine.Recover(paths, cancellationToken); + return result.Succeeded; + } + + private static IReadOnlyList CreateDeploymentPackages(LaunchPreparationRequest request) + { + return request.Versions + .Select((version, index) => CreateDeploymentPackage(request, version, index)) + .ToList(); + } + + private static DeploymentPackage CreateDeploymentPackage( + LaunchPreparationRequest request, + LauncherContentVersion version, + int index) + { + ArgumentNullException.ThrowIfNull(version); + + string packageRoot = LauncherContentPathResolver.ResolveVersionPath( + request.Paths, + version.ContentKey)?.FullPath + ?? string.Empty; + return new DeploymentPackage(packageRoot, index); + } + +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/FileSystemDeploymentService.cs b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemDeploymentService.cs new file mode 100644 index 00000000..28356a58 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemDeploymentService.cs @@ -0,0 +1,567 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Mods.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. +/// +internal sealed class FileSystemDeploymentService +{ + private readonly DeploymentFileTransaction _fileTransaction; + + private readonly ILogger _logger; + + private readonly DeploymentStateStore _stateStore; + + public FileSystemDeploymentService( + IHardLinkCreator hardLinkCreator, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(hardLinkCreator); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _fileTransaction = new DeploymentFileTransaction(hardLinkCreator, logger); + _stateStore = new DeploymentStateStore(_logger); + } + + /// + /// Prepares the game directory by deploying the selected packages. + /// + public DeploymentResult Prepare( + LauncherPaths paths, + IReadOnlyList packages, + IReadOnlyList disabledTargetRelativePaths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(packages); + ArgumentNullException.ThrowIfNull(disabledTargetRelativePaths); + + IReadOnlyList normalizedDisabledTargetRelativePaths = disabledTargetRelativePaths + .Where(path => !string.IsNullOrWhiteSpace(path)) + .Select(path => LexicalPath.NormalizeRelativePath(path.Trim())) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return ExecuteLocked( + paths, + () => PrepareWithLock( + paths, + packages, + normalizedDisabledTargetRelativePaths, + cancellationToken), + DeploymentFailureKind.FileSystem, + exception => _logger.LogError( + exception, + "Deployment preparation failed before deployment recovery could run.")); + } + + /// + /// Cleans the active deployment from the game directory. + /// + public DeploymentResult Cleanup(LauncherPaths paths, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(paths); + + return ExecuteLocked( + paths, + () => RestoreCore(paths, RestoreOperationKind.Cleanup, cancellationToken), + DeploymentFailureKind.FileSystem, + exception => _logger.LogError(exception, "Deployment cleanup failed.")); + } + + /// + /// Recovers interrupted deployment work from the persisted manifest or journal. + /// + public DeploymentResult Recover(LauncherPaths paths, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(paths); + + return ExecuteLocked( + paths, + () => RestoreCore(paths, RestoreOperationKind.Recovery, cancellationToken), + DeploymentFailureKind.Manifest, + exception => _logger.LogError(exception, "Deployment recovery failed.")); + } + + /// + /// Prepares a deployment while the deployment operation lock is held. + /// + private DeploymentResult PrepareWithLock( + LauncherPaths launcherPaths, + IReadOnlyList packages, + IReadOnlyList disabledTargetRelativePaths, + CancellationToken cancellationToken) + { + List sourceStreams = []; + long preparationStartedTimestamp = Stopwatch.GetTimestamp(); + try + { + DeploymentResult cleanupResult = RestoreCore( + launcherPaths, + RestoreOperationKind.Cleanup, + cancellationToken); + if (!cleanupResult.Succeeded) + { + return cleanupResult; + } + + string deploymentId = Guid.NewGuid().ToString("N"); + DeploymentStatePaths paths = DeploymentStateStore.CreatePaths(launcherPaths, deploymentId); + OwnedDirectoryTree.EnsureExists(launcherPaths.OwnedGameDataDirectory, paths.DeploymentDirectory); + OwnedDirectoryTree.EnsureExists(paths.DeploymentDirectory, paths.BackupDirectory); + if (File.Exists(paths.JournalPath)) + { + File.Delete(paths.JournalPath); + } + + using FileStream journal = DeploymentStateStore.OpenJournal(paths.JournalPath); + string gameRoot = PhysicalDirectoryPath.ResolveExisting(launcherPaths.GameDirectory); + string gameRootIdentity = DeploymentStateStore.GetGameRootIdentity(gameRoot); + DeploymentStateStore.AppendJournalDurably( + journal, + DeploymentJournalRecord.DeploymentStarted( + deploymentId, + gameRoot, + gameRootIdentity, + launcherPaths.Game)); + + IReadOnlyList files = + DeploymentFilePlanner.ResolveDeploymentFiles(packages); + sourceStreams.AddRange(files.Select(file => DeploymentFileTransaction.OpenDeploymentSource(file.SourcePath))); + var sourceFingerprints = new DeploymentFileFingerprint[files.Count]; + Parallel.For( + 0, + files.Count, + new ParallelOptions + { + CancellationToken = cancellationToken, + MaxDegreeOfParallelism = Math.Min(Environment.ProcessorCount, 4) + }, + index => + sourceFingerprints[index] = DeploymentFileTransaction.ComputeFileFingerprint(sourceStreams[index])); + (List Entries, HashSet CreatedDirectories) deployment = + ApplyDeploymentFiles( + launcherPaths, + disabledTargetRelativePaths, + paths, + journal, + deploymentId, + files, + sourceStreams, + sourceFingerprints, + cancellationToken); + + DeploymentManifestDocument document = new( + DeploymentStateStore.CurrentSchemaVersion, + deploymentId, + deployment.Entries, + deployment.CreatedDirectories.OrderByDescending(path => path.Length).ToList(), + gameRoot, + gameRootIdentity, + launcherPaths.Game); + cancellationToken.ThrowIfCancellationRequested(); + DeploymentStateStore.FlushJournal(journal); + DeploymentStateStore.WriteManifest(paths.ActiveManifestPath, document); + _logger.LogInformation( + "Prepared deployment {DeploymentId} with {FileCount} file(s) in {ElapsedMilliseconds} ms.", + deploymentId, + deployment.Entries.Count, + (long)Stopwatch.GetElapsedTime(preparationStartedTimestamp).TotalMilliseconds); + return DeploymentResult.Success(); + } + catch (OperationCanceledException) + { + _logger.LogInformation("Deployment preparation was canceled; recovering any partial game-folder mutation."); + RestoreCore(launcherPaths, RestoreOperationKind.Recovery, CancellationToken.None); + throw; + } + catch (Exception exception) + { + _logger.LogError(exception, "Deployment preparation failed."); + DeploymentFailure prepareFailure = new( + DeploymentFailureKind.FileSystem, + launcherPaths.GameDirectory, + exception.Message); + + DeploymentResult recoveryResult; + try + { + recoveryResult = RestoreCore( + launcherPaths, + RestoreOperationKind.Recovery, + CancellationToken.None); + } + catch (Exception recoveryException) + { + _logger.LogError(recoveryException, "Deployment recovery failed after preparation failure."); + recoveryResult = DeploymentResult.Failure( + new[] + { + new DeploymentFailure( + DeploymentFailureKind.Manifest, + launcherPaths.GameDirectory, + recoveryException.Message) + }); + } + + if (recoveryResult.Succeeded) + { + return DeploymentResult.Failure(new[] { prepareFailure }); + } + + return DeploymentResult.Failure( + new[] { prepareFailure }.Concat(recoveryResult.Failures).ToArray()); + } + finally + { + foreach (FileStream sourceStream in sourceStreams) + { + sourceStream.Dispose(); + } + } + } + + /// + /// Applies every planned file mutation and returns the state that must be committed to the active manifest. + /// + private (List Entries, HashSet CreatedDirectories) ApplyDeploymentFiles( + LauncherPaths launcherPaths, + IReadOnlyList disabledTargetRelativePaths, + DeploymentStatePaths paths, + FileStream journal, + string deploymentId, + IReadOnlyList files, + IReadOnlyList sourceStreams, + IReadOnlyList sourceFingerprints, + CancellationToken cancellationToken) + { + 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( + launcherPaths, + disabledTargetRelativePaths, + paths, + journal, + deploymentId, + deployedTargetPaths, + backedUpTargetPaths, + entries, + cancellationToken); + + for (int fileIndex = 0; fileIndex < files.Count; fileIndex++) + { + cancellationToken.ThrowIfCancellationRequested(); + ResolvedDeploymentFile file = files[fileIndex]; + string targetPath = DeploymentPathResolver.ResolveGamePath(launcherPaths, file.TargetRelativePath); + EnsureTargetDirectory(launcherPaths, targetPath, journal, createdDirectories); + + DeploymentFileTransaction.EnsureSafeGameMutationPath(launcherPaths, targetPath); + if (!backedUpTargetPaths.TryGetValue(file.TargetRelativePath, out DeploymentBackupDocument? backup) && + File.Exists(targetPath)) + { + backup = _fileTransaction.BackupTargetFile( + paths, + journal, + deploymentId, + file.TargetRelativePath, + targetPath); + backedUpTargetPaths[file.TargetRelativePath] = backup; + } + + FileStream source = sourceStreams[fileIndex]; + DeploymentFileFingerprint sourceFingerprint = sourceFingerprints[fileIndex]; + string stagingPath = DeploymentFileTransaction.CreateSiblingStagingPath( + targetPath, + deploymentId, + "deploy"); + string stagingRelativePath = DeploymentPathResolver.ToRelativeManifestPath( + launcherPaths.GameDirectory, + stagingPath); + DeploymentStateStore.AppendJournalDurably(journal, DeploymentJournalRecord.FileDeploymentStarted( + file.TargetRelativePath, + backup?.RelativePath, + sourceFingerprint, + backup?.Fingerprint, + stagingRelativePath)); + + DeploymentFileTransaction.EnsureSafeGameMutationPath(launcherPaths, targetPath); + (DeploymentMethod Method, DeploymentFileFingerprint Fingerprint, string? FileIdentity) deployedFile = + _fileTransaction.DeployFile( + source, + file.SourcePath, + targetPath, + stagingPath, + sourceFingerprint); + DeploymentStateStore.AppendJournal(journal, DeploymentJournalRecord.FileDeployed( + file.TargetRelativePath, + deployedFile.Method, + backup?.RelativePath, + deployedFile.Fingerprint, + backup?.Fingerprint, + stagingRelativePath, + deployedFile.FileIdentity)); + cancellationToken.ThrowIfCancellationRequested(); + + entries.Add(new DeploymentFileDocument( + file.TargetRelativePath, + deployedFile.Method, + backup?.RelativePath, + deployedFile.Fingerprint, + backup?.Fingerprint, + stagingRelativePath, + backup?.StagingRelativePath, + DeployedFileIdentity: deployedFile.FileIdentity)); + } + + return (entries, createdDirectories); + } + + private static void EnsureTargetDirectory( + LauncherPaths launcherPaths, + string targetPath, + FileStream journal, + HashSet createdDirectories) + { + DeploymentFileTransaction.EnsureSafeGameMutationPath(launcherPaths, targetPath); + string targetDirectory = Path.GetDirectoryName(targetPath) ?? launcherPaths.GameDirectory; + foreach (string directory in DeploymentFilePlanner.GetDirectoriesToCreate( + launcherPaths.GameDirectory, + targetDirectory)) + { + if (Directory.Exists(directory)) + { + continue; + } + + DeploymentFileTransaction.EnsureSafeGameMutationPath(launcherPaths, directory); + string relativeDirectory = DeploymentPathResolver.ToRelativeManifestPath( + launcherPaths.GameDirectory, + directory); + DeploymentStateStore.AppendJournalDurably( + journal, + DeploymentJournalRecord.DirectoryCreated(relativeDirectory)); + Directory.CreateDirectory(directory); + DeploymentFileTransaction.EnsureSafeGameMutationPath(launcherPaths, directory); + createdDirectories.Add(relativeDirectory); + } + } + + private DeploymentResult ExecuteLocked( + LauncherPaths paths, + Func operation, + DeploymentFailureKind failureKind, + Action logFailure) + { + try + { + using FileStream deploymentLock = DeploymentStateStore.AcquireDeploymentLock(paths); + return operation(); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + logFailure(exception); + return DeploymentResult.Failure( + new[] + { + new DeploymentFailure( + failureKind, + paths.GameDirectory, + exception.Message) + }); + } + } + + /// + /// Restores a deployment during explicit cleanup or interrupted-state recovery while the operation lock is held. + /// + private DeploymentResult RestoreCore( + LauncherPaths paths, + RestoreOperationKind operationKind, + CancellationToken cancellationToken) + { + long restoreStartedTimestamp = Stopwatch.GetTimestamp(); + DeploymentManifestDocument? manifest = RestoreActiveDeployment(paths, cancellationToken); + if (manifest is null) + { + return DeploymentResult.Success(); + } + + if (operationKind == RestoreOperationKind.Recovery) + { + _logger.LogInformation("Recovered deployment state for {DeploymentId}.", manifest.DeploymentId); + } + else + { + _logger.LogInformation( + "Cleaned deployment {DeploymentId} in {ElapsedMilliseconds} ms.", + manifest.DeploymentId, + (long)Stopwatch.GetElapsedTime(restoreStartedTimestamp).TotalMilliseconds); + } + + return DeploymentResult.Success(); + } + + /// + /// Restores game files and removes the durable state for one active or interrupted deployment. + /// + private DeploymentManifestDocument? RestoreActiveDeployment( + LauncherPaths paths, + CancellationToken cancellationToken) + { + DeploymentStatePaths deploymentPaths = DeploymentStateStore.CreatePaths(paths, string.Empty); + DeploymentManifestDocument? manifest = _stateStore.ReadManifestOrJournal(paths, deploymentPaths); + + if (manifest is null) + { + DeploymentStateStore.DeleteDeploymentStateFiles(deploymentPaths); + return null; + } + + CleanupManifest(paths, deploymentPaths, manifest, cancellationToken); + DeleteEmptyBackupDirectories(deploymentPaths, cancellationToken); + DeploymentStateStore.DeleteDeploymentStateFiles(deploymentPaths); + return manifest; + } + + /// + /// Backs up deployment targets that should be hidden without deploying a replacement file. + /// + private void BackupDisabledTargets( + LauncherPaths launcherPaths, + IReadOnlyList disabledTargetRelativePaths, + DeploymentStatePaths deploymentPaths, + FileStream journal, + string deploymentId, + IReadOnlySet deployedTargetPaths, + Dictionary backedUpTargetPaths, + List entries, + CancellationToken cancellationToken) + { + foreach (string disabledTargetRelativePath in disabledTargetRelativePaths) + { + cancellationToken.ThrowIfCancellationRequested(); + + string targetRelativePath = DeploymentPathResolver.NormalizeManifestPath(disabledTargetRelativePath); + if (backedUpTargetPaths.ContainsKey(targetRelativePath)) + { + continue; + } + + string targetPath = DeploymentPathResolver.ResolveGamePath(launcherPaths, targetRelativePath); + DeploymentFileTransaction.EnsureSafeGameMutationPath(launcherPaths, targetPath); + if (!File.Exists(targetPath)) + { + _logger.LogDebug( + "Skipped disabling base game file {FileName} because it does not exist.", + Path.GetFileName(targetPath)); + continue; + } + + DeploymentBackupDocument backup = _fileTransaction.BackupTargetFile( + deploymentPaths, + journal, + deploymentId, + targetRelativePath, + targetPath); + backedUpTargetPaths[targetRelativePath] = backup; + + if (!deployedTargetPaths.Contains(targetRelativePath)) + { + entries.Add(new DeploymentFileDocument( + targetRelativePath, + DeploymentMethod.Copy, + backup.RelativePath, + null, + backup.Fingerprint, + null, + backup.StagingRelativePath)); + } + + _logger.LogDebug( + "Temporarily disabled base game file {FileName} for modded launch deployment.", + Path.GetFileName(targetPath)); + } + } + + /// + /// Replays persisted deployment state to remove deployed files and restore original backups. + /// + private void CleanupManifest( + LauncherPaths paths, + DeploymentStatePaths deploymentPaths, + DeploymentManifestDocument manifest, + CancellationToken cancellationToken) + { + using FileStream journal = DeploymentStateStore.OpenJournal(deploymentPaths.JournalPath); + foreach (DeploymentFileDocument file in manifest.Files) + { + cancellationToken.ThrowIfCancellationRequested(); + _fileTransaction.RestoreFile(paths, deploymentPaths, manifest, file, journal); + } + + foreach (string relativeDirectory in manifest.CreatedDirectories.OrderByDescending(path => path.Length)) + { + cancellationToken.ThrowIfCancellationRequested(); + + string directoryPath = DeploymentPathResolver.ResolveGamePath(paths, relativeDirectory); + DeploymentFileTransaction.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)); + } + } + + private static void DeleteEmptyBackupDirectories( + DeploymentStatePaths deploymentPaths, + CancellationToken cancellationToken) + { + string backupRoot = Path.Combine( + deploymentPaths.DeploymentDirectory, + DeploymentStateStore.BackupsDirectoryName); + if (!Directory.Exists(backupRoot)) + { + return; + } + + cancellationToken.ThrowIfCancellationRequested(); + OwnedDirectoryTree.DeleteEmptyDirectories( + new OwnedContentPath( + deploymentPaths.DeploymentDirectory, + backupRoot)); + } + + private enum RestoreOperationKind + { + Cleanup, + Recovery + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionService.cs b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionService.cs new file mode 100644 index 00000000..32ad3b70 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionService.cs @@ -0,0 +1,548 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.IO; +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.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Integrity.Contracts; +using GenLauncherGO.Infrastructure.Integrity.Support; +using GenLauncherGO.Infrastructure.Launching.Contracts; +using GenLauncherGO.Infrastructure.Mods.Support; +using GenLauncherGO.Infrastructure.Remote.Contracts; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Services; +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. +/// +internal sealed class FileSystemLaunchContentIntegrityResolutionService : ILaunchContentIntegrityResolutionService +{ + private readonly IRemoteAssetDownloader _assetDownloader; + + private readonly ILauncherContentCatalog _catalog; + private readonly IContentIntegrityService _integrityService; + + private readonly ILogger _logger; + + private readonly ManagedPackageSourceResolver _packageSourceResolver; + + private readonly IS3PackageUpdater _s3PackageUpdater; + + private readonly ISingleFilePackageUpdater _singleFilePackageUpdater; + + private readonly ILaunchContentIntegrityTargetBuilder _targetBuilder; + + public FileSystemLaunchContentIntegrityResolutionService( + IContentIntegrityService integrityService, + ILaunchContentIntegrityTargetBuilder targetBuilder, + ManagedPackageSourceResolver packageSourceResolver, + IS3PackageUpdater s3PackageUpdater, + ISingleFilePackageUpdater singleFilePackageUpdater, + IRemoteAssetDownloader assetDownloader, + ILauncherContentCatalog catalog, + ILogger logger) + { + _integrityService = integrityService ?? throw new ArgumentNullException(nameof(integrityService)); + _targetBuilder = targetBuilder ?? throw new ArgumentNullException(nameof(targetBuilder)); + _packageSourceResolver = packageSourceResolver ?? + throw new ArgumentNullException(nameof(packageSourceResolver)); + _s3PackageUpdater = s3PackageUpdater ?? throw new ArgumentNullException(nameof(s3PackageUpdater)); + _singleFilePackageUpdater = singleFilePackageUpdater ?? + throw new ArgumentNullException(nameof(singleFilePackageUpdater)); + _assetDownloader = assetDownloader ?? throw new ArgumentNullException(nameof(assetDownloader)); + _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); + _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( + request.Paths, + contexts.Select(context => context.Target).ToList(), + cancellationToken).ConfigureAwait(false); + return new LaunchContentIntegrityVerificationResult(report, contexts); + } + + public async Task InitializeUntrackedManagedCachesAsync( + LaunchContentIntegrityResolutionRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + IReadOnlySet untrackedManagedTargetIds = GetUntrackedManagedTargetIds(request.Report); + var cacheContexts = request.TargetContexts + .Where(context => + context.IsCache && + untrackedManagedTargetIds.Contains(context.Target.Id)) + .ToList(); + + bool initializedAny = false; + foreach (LaunchContentIntegrityTargetContext context in cacheContexts) + { + if (!await _integrityService.CaptureSnapshotIfMatchesExpectedFileSetAsync( + request.Paths, + context.Target, + BuildExpectedRemoteCachePaths(context), + cancellationToken).ConfigureAwait(false)) + { + 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); + (IReadOnlySet TrustAsManualTargetIds, IReadOnlySet AbsorbTargetIds, IReadOnlySet RepairTargetIds, IReadOnlyList ManagedTargetIdsInReportOrder) issueIndex = IndexResolutionIssues(request.Report); + + foreach (LaunchContentIntegrityTargetContext context in request.TargetContexts.Where(context => + issueIndex.TrustAsManualTargetIds.Contains(context.Target.Id))) + { + context.Version.Installation.ContentSourceKind = ContentSourceKind.Manual; + ContentIntegrityTarget manualTarget = context.Target with + { + SourceKind = ContentSourceKind.Manual + }; + await _integrityService.CaptureSnapshotAsync(request.Paths, manualTarget, cancellationToken).ConfigureAwait(false); + } + + _catalog.SaveLauncherData(); + + foreach (LaunchContentIntegrityTargetContext context in request.TargetContexts.Where(context => + issueIndex.AbsorbTargetIds.Contains(context.Target.Id))) + { + await _integrityService.CaptureSnapshotAsync(request.Paths, context.Target, cancellationToken).ConfigureAwait(false); + } + + await _integrityService.ApplyCleanupAsync( + request.Report, + request.TargetContexts.Select(context => context.Target).ToList(), + cancellationToken).ConfigureAwait(false); + + foreach (LaunchContentIntegrityTargetContext context in request.TargetContexts.Where(context => + issueIndex.RepairTargetIds.Contains(context.Target.Id))) + { + if (context.IsCache) + { + await RefreshManagedCacheAsync(context, cancellationToken).ConfigureAwait(false); + progress?.Report(LaunchContentIntegrityResolutionProgress.Complete(context.Target.Id)); + } + else + { + await RepairManagedPackageAsync( + request.Paths, + context, + request.Report, + new TargetPackageProgress(context.Target.Id, progress), + cancellationToken).ConfigureAwait(false); + } + } + + foreach (string targetId in issueIndex.ManagedTargetIdsInReportOrder) + { + if (contextIndex.TryGetValue(targetId, out LaunchContentIntegrityTargetContext? context)) + { + await _integrityService.CaptureSnapshotAsync(request.Paths, context.Target, cancellationToken).ConfigureAwait(false); + } + } + } + + public async Task RegisterManualImportAsync( + LaunchContentIntegrityVersionRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + request.Version.Installation.ContentSourceKind = ContentSourceKind.Manual; + _catalog.SaveLauncherData(); + + IReadOnlyList contexts = BuildSingleVersionContexts(request); + await _integrityService.CaptureSnapshotAsync( + request.Paths, + contexts.First(context => !context.IsCache).Target, + cancellationToken).ConfigureAwait(false); + + if (request.Version.ModificationType == ModificationType.Mod) + { + await _integrityService.CaptureSnapshotAsync( + request.Paths, + contexts.First(context => context.IsCache).Target, + cancellationToken).ConfigureAwait(false); + } + } + + public async Task CaptureManagedInstallSnapshotAsync( + LaunchContentIntegrityVersionRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + if (!request.Version.EffectiveContentSourceKind.IsManagedRemote()) + { + return; + } + + IReadOnlyList contexts = BuildSingleVersionContexts(request); + await _integrityService.CaptureSnapshotAsync( + request.Paths, + contexts.First(context => !context.IsCache).Target, + cancellationToken).ConfigureAwait(false); + + if (request.Version.ModificationType == ModificationType.Mod) + { + LaunchContentIntegrityTargetContext cacheContext = contexts.First(context => context.IsCache); + if (!await _integrityService.CaptureSnapshotIfMatchesExpectedFileSetAsync( + request.Paths, + cacheContext.Target, + BuildExpectedRemoteCachePaths(cacheContext), + cancellationToken).ConfigureAwait(false)) + { + await RefreshManagedCacheAsync(cacheContext, cancellationToken).ConfigureAwait(false); + await _integrityService.CaptureSnapshotAsync(request.Paths, cacheContext.Target, cancellationToken).ConfigureAwait(false); + } + } + } + + public async Task CaptureManualImageSnapshotAsync( + LaunchContentIntegrityVersionRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + if (request.Version.EffectiveContentSourceKind != ContentSourceKind.Manual) + { + return; + } + + await _integrityService.CaptureSnapshotAsync( + request.Paths, + BuildSingleVersionContexts(request).First(context => context.IsCache).Target, + cancellationToken).ConfigureAwait(false); + } + + private IReadOnlyList BuildSingleVersionContexts( + LaunchContentIntegrityVersionRequest request) + { + return _targetBuilder.BuildTargets( + new LaunchContentIntegrityTargetRequest( + request.Paths, + new[] { request.Version }, + request.AllVersions, + request.CacheDisplayNameSuffix)); + } + + /// + /// Repairs a managed remote package. + /// + private async Task RepairManagedPackageAsync( + LauncherPaths paths, + LaunchContentIntegrityTargetContext context, + ContentIntegrityReport report, + IProgress progress, + CancellationToken cancellationToken) + { + var installedPath = new OwnedContentPath(paths.ModsDirectory, context.Target.RootDirectory); + var packagePaths = PackageUpdatePathSet.Create( + paths, + installedPath, + installedPath); + ManagedPackageSourceResolver.PackageSource? source = await _packageSourceResolver.ResolveAsync( + context.Version, + cancellationToken).ConfigureAwait(false); + if (source is ManagedPackageSourceResolver.PackageSource.S3 s3Source) + { + IReadOnlySet hashCheckedExtensions = + S3HashValidationPolicy.CreateRepairHashCheckedExtensions(s3Source.Files); + + IReadOnlyList repairFiles = SelectS3FileRepairEntries( + report, + context.Target.Id, + s3Source.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, + s3Source.Request, + installedPath, + hashCheckedExtensions), + progress, + cancellationToken).ConfigureAwait(false); + return; + } + + _logger.LogInformation( + "Repairing S3 package {ContentName} with full package replacement.", + context.Version.DisplayName); + + await _s3PackageUpdater.UpdateAsync( + new S3PackageUpdateRequest( + s3Source.Files, + s3Source.Request, + packagePaths, + hashCheckedExtensions), + progress, + cancellationToken).ConfigureAwait(false); + return; + } + + if (source is ManagedPackageSourceResolver.PackageSource.SingleFile singleFileSource) + { + await _singleFilePackageUpdater.UpdateAsync( + singleFileSource.Metadata, + packagePaths, + progress, + cancellationToken).ConfigureAwait(false); + 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 => LexicalPath.NormalizeRelativePath(issue.RelativePath)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + List selectedFiles = []; + foreach (RemoteFileManifestEntry file in files) + { + string manifestRelativePath = ManifestPathResolver.NormalizeForManifestIndex(file.FileName); + string installedRelativePath = + ManifestPathResolver.NormalizeInstalledPathForManifestIndex(file.FileName); + bool matchesIssue = remainingIssuePaths.Remove(manifestRelativePath); + matchesIssue |= remainingIssuePaths.Remove(installedRelativePath); + if (matchesIssue) + { + selectedFiles.Add(file); + } + } + + return remainingIssuePaths.Count == 0 + ? selectedFiles + : Array.Empty(); + } + + private static IReadOnlySet GetUntrackedManagedTargetIds(ContentIntegrityReport report) + { + HashSet untrackedManagedTargetIds = new(StringComparer.Ordinal); + HashSet targetIdsWithOtherIssues = new(StringComparer.Ordinal); + foreach (ContentIntegrityIssue issue in report.Issues) + { + if (issue.Kind != IntegrityIssueKind.Untracked) + { + targetIdsWithOtherIssues.Add(issue.TargetId); + } + else if (issue.SourceKind.IsManagedRemote()) + { + untrackedManagedTargetIds.Add(issue.TargetId); + } + } + + untrackedManagedTargetIds.ExceptWith(targetIdsWithOtherIssues); + return untrackedManagedTargetIds; + } + + private static ( + IReadOnlySet TrustAsManualTargetIds, + IReadOnlySet AbsorbTargetIds, + IReadOnlySet RepairTargetIds, + IReadOnlyList ManagedTargetIdsInReportOrder) IndexResolutionIssues(ContentIntegrityReport report) + { + HashSet trustAsManualTargetIds = new(StringComparer.Ordinal); + HashSet absorbTargetIds = new(StringComparer.Ordinal); + HashSet repairTargetIds = new(StringComparer.Ordinal); + HashSet seenManagedTargetIds = new(StringComparer.Ordinal); + List managedTargetIds = []; + foreach (ContentIntegrityIssue issue in report.Issues) + { + switch (issue.Action) + { + case IntegrityIssueAction.TrustAsManual: + trustAsManualTargetIds.Add(issue.TargetId); + break; + case IntegrityIssueAction.Absorb: + absorbTargetIds.Add(issue.TargetId); + break; + case IntegrityIssueAction.Repair: + case IntegrityIssueAction.Redownload: + repairTargetIds.Add(issue.TargetId); + break; + } + + if (issue.SourceKind.IsManagedRemote() && seenManagedTargetIds.Add(issue.TargetId)) + { + managedTargetIds.Add(issue.TargetId); + } + } + + return (trustAsManualTargetIds, absorbTargetIds, repairTargetIds, managedTargetIds); + } + + /// + /// Refreshes a managed launcher-owned cache target from remote asset links. + /// + private async Task RefreshManagedCacheAsync( + LaunchContentIntegrityTargetContext context, + CancellationToken cancellationToken) + { + ContentIntegrityTarget target = context.Target; + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + target.RootDirectory, + "Content metadata paths"); + Directory.CreateDirectory(target.RootDirectory); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + target.RootDirectory, + "Content metadata paths"); + + IReadOnlyList<(Uri SourceUri, string DestinationPath)> assets = + ModificationImageCachePath.ResolveRemoteAssets(context.Version, target.RootDirectory); + foreach (string filePath in EnumerateFilesWithoutLinks(target.RootDirectory).ToList()) + { + cancellationToken.ThrowIfCancellationRequested(); + string relativePath = ContentIntegrityPath.GetRelativePath(target.RootDirectory, filePath); + if (ContentIntegrityPath.IsIgnored(target, 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 ((Uri sourceUri, string destinationPath) in assets) + { + await _assetDownloader.DownloadIfMissingAsync( + sourceUri, + destinationPath, + cancellationToken).ConfigureAwait(false); + } + } + + private static HashSet BuildExpectedRemoteCachePaths(LaunchContentIntegrityTargetContext context) + { + return ModificationImageCachePath.ResolveRemoteAssets(context.Version, context.Target.RootDirectory) + .Select(asset => ContentIntegrityPath.GetRelativePath( + context.Target.RootDirectory, + asset.DestinationPath)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + } + + /// + /// Enumerates files without following linked directories into paths the launcher does not own. + /// + private static IEnumerable EnumerateFilesWithoutLinks(string rootDirectory) + { + return Directory.EnumerateFiles( + rootDirectory, + "*", + FileSystemPathSafety.CreateRecursiveNoLinksOptions()); + } + + /// + /// Enumerates directories without following linked directories into paths the launcher does not own. + /// + 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 + { + private readonly IProgress? _progress; + private readonly string _targetId; + + public TargetPackageProgress( + string targetId, + IProgress? progress) + { + ArgumentException.ThrowIfNullOrWhiteSpace(targetId); + + _targetId = targetId; + _progress = progress; + } + + public void Report(PackageUpdateProgress value) + { + _progress?.Report(LaunchContentIntegrityResolutionProgress.Package(_targetId, value)); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilder.cs b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilder.cs new file mode 100644 index 00000000..1d4f80da --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilder.cs @@ -0,0 +1,183 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Mods.Services; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Launching.Contracts; +using GenLauncherGO.Infrastructure.Mods.Support; +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. +/// +internal sealed class FileSystemLaunchContentIntegrityTargetBuilder : ILaunchContentIntegrityTargetBuilder +{ + private static readonly HashSet _emptyIgnoredPaths = new(StringComparer.OrdinalIgnoreCase); + + private readonly ILogger _logger; + + public FileSystemLaunchContentIntegrityTargetBuilder( + ILogger? logger = null) + { + _logger = logger ?? NullLogger.Instance; + } + + public IReadOnlyList BuildTargets( + LaunchContentIntegrityTargetRequest request) + { + ArgumentNullException.ThrowIfNull(request); + + var contexts = new List(); + foreach (LauncherContentVersion version in request.ActiveVersions) + { + contexts.Add(new LaunchContentIntegrityTargetContext( + CreatePackageTarget(request, version), + version, + false)); + + if (version.ModificationType == ModificationType.Mod) + { + contexts.Add(new LaunchContentIntegrityTargetContext( + CreateCacheTarget(request, version), + version, + true)); + } + } + + if (contexts.Count > 0) + { + _logger.LogDebug( + "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; + } + + private static ContentIntegrityTarget CreatePackageTarget( + LaunchContentIntegrityTargetRequest request, + LauncherContentVersion version) + { + OwnedContentPath packagePath = LauncherContentPathResolver.ResolveVersionPath( + request.Paths, + version.ContentKey) + ?? throw new InvalidDataException( + "Content metadata did not resolve to a supported launcher content path."); + string packageDirectory = FileSystemPathSafety.ResolveOwnedSubpath( + packagePath.OwnerRoot, + packagePath.FullPath, + "Content metadata paths", + "a launcher-owned directory"); + + return new ContentIntegrityTarget( + CreateTargetId("package", version.ContentKey), + version.DisplayName, + packageDirectory, + version.EffectiveContentSourceKind, + _emptyIgnoredPaths); + } + + private ContentIntegrityTarget CreateCacheTarget( + LaunchContentIntegrityTargetRequest request, + LauncherContentVersion version) + { + string cacheDirectory = ModificationImageCachePath.ResolveDirectory( + request.Paths, + version.ModificationType, + version.Name); + HashSet ignoredPaths = BuildCacheIgnoredPaths(request, version, cacheDirectory); + + return new ContentIntegrityTarget( + CreateTargetId("cache", version.ContentKey), + version.DisplayName + " " + request.CacheDisplayNameSuffix, + cacheDirectory, + version.EffectiveContentSourceKind, + ignoredPaths); + } + + /// + /// Builds ignored cache paths for inactive versions and the active version's locally serialized palette. + /// + private HashSet BuildCacheIgnoredPaths( + LaunchContentIntegrityTargetRequest request, + LauncherContentVersion 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 ignoredBaseNames = request.AllVersions + .Where(candidate => + candidate.ContentKey != version.ContentKey && + candidate.ContentKey.HasName(version.Name)) + .SelectMany(ModificationImageCachePath.GetOwnedBaseNames) + .Where(value => !string.IsNullOrWhiteSpace(value)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + if (version.Theme != null) + { + ignoredBaseNames.Add(LauncherContentTheme.ResolveCacheBaseName(version.Version)); + } + + foreach (string filePath in Directory.EnumerateFiles( + cacheDirectory, + "*", + FileSystemPathSafety.CreateRecursiveNoLinksOptions())) + { + string relativePath = LexicalPath.GetRelativePath(cacheDirectory, filePath); + if (ignoredBaseNames.Contains(Path.GetFileNameWithoutExtension(filePath))) + { + ignoredPaths.Add(relativePath); + } + } + + if (ignoredPaths.Count > 0) + { + _logger.LogDebug( + "Ignored {IgnoredPathCount} local or inactive image-cache file(s) while building integrity target for {ContentName} {ContentVersion}.", + ignoredPaths.Count, + version.Name, + version.Version); + } + + return ignoredPaths; + } + + /// + /// Creates a stable target identifier. + /// + private static string CreateTargetId(string prefix, LauncherContentKey contentKey) + { + return string.Concat(prefix, ":", contentKey.ToStableString()); + } + +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryService.cs b/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryService.cs new file mode 100644 index 00000000..f225c627 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryService.cs @@ -0,0 +1,93 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Launching.Services; + +/// +/// Discovers Windows game and World Builder executables through file-system probes. +/// +internal sealed class WindowsGameExecutableDiscoveryService : IGameExecutableDiscoveryService +{ + private readonly ILogger _logger; + private readonly LauncherRuntimePathContext _runtimePathContext; + + public WindowsGameExecutableDiscoveryService( + LauncherRuntimePathContext runtimePathContext, + ILogger logger) + { + _runtimePathContext = runtimePathContext ?? throw new ArgumentNullException(nameof(runtimePathContext)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public IReadOnlyList GetGameClients() + { + LauncherPaths paths = _runtimePathContext.ActivePaths; + return Discover( + LauncherFileSystemLayout.GetBuiltInGameExecutableNames(paths.Game), + paths); + } + + public IReadOnlyList GetWorldBuilders() + { + LauncherPaths paths = _runtimePathContext.ActivePaths; + return Discover( + LauncherFileSystemLayout.GetBuiltInWorldBuilderExecutableNames(paths.Game), + paths); + } + + public bool IsExecutableAvailable(string? executableName) + { + if (string.IsNullOrWhiteSpace(executableName)) + { + return false; + } + + LauncherPaths paths = _runtimePathContext.ActivePaths; + return IsExecutableAvailable(executableName, paths); + } + + /// + /// Probes one executable against an immutable active-path snapshot. + /// + private bool IsExecutableAvailable( + string executableName, + LauncherPaths paths) + { + try + { + string normalizedName = LauncherFileSystemLayout.NormalizeExecutableFileName(executableName); + string executablePath = Path.Combine(paths.GameDirectory, normalizedName); + return File.Exists(executablePath) && !FileSystemPathSafety.IsReparsePoint(executablePath); + } + catch (Exception exception) when ( + exception is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) + { + _logger.LogWarning( + exception, + "Could not inspect executable availability for {ExecutableName}.", + Path.GetFileName(executableName)); + return false; + } + } + + private IReadOnlyList Discover( + IReadOnlyList executableNames, + LauncherPaths paths) + { + var executables = new List(executableNames.Count); + foreach (string executableName in executableNames) + { + executables.Add(new BuiltInExecutable( + executableName, + IsExecutableAvailable(executableName, paths))); + } + + return executables; + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameProcessLauncher.cs b/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameProcessLauncher.cs new file mode 100644 index 00000000..179f51c7 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/WindowsGameProcessLauncher.cs @@ -0,0 +1,141 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Infrastructure.Common; +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. +/// +internal sealed class WindowsGameProcessLauncher : IGameProcessLauncher +{ + /// + /// The observed game-process running time required to treat a launch as successful. + /// + private const int SuccessfulLaunchThresholdMilliseconds = 12000; + + private readonly ILogger _logger; + + private readonly IProcessFamilyLauncher _processFamilyLauncher; + + public WindowsGameProcessLauncher( + IProcessFamilyLauncher processFamilyLauncher, + ILogger logger) + { + _processFamilyLauncher = + processFamilyLauncher ?? throw new ArgumentNullException(nameof(processFamilyLauncher)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task StartAsync( + GameLaunchRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + string executablePath = Path.Combine(request.GameDirectory, request.ExecutableName); + EnsureExecutableCanLaunch(executablePath); + IProcessFamilyLaunchOperation operation = await _processFamilyLauncher.StartAsync( + executablePath, + request.Arguments, + request.GameDirectory, + cancellationToken).ConfigureAwait(false); + return new WindowsGameProcessLaunchOperation( + request.TargetKind, + request.ExecutableName, + operation, + _logger); + } + + private static void EnsureExecutableCanLaunch(string executablePath) + { + if (!File.Exists(executablePath)) + { + throw new FileNotFoundException("The selected executable is no longer available.", executablePath); + } + + if (FileSystemPathSafety.IsReparsePoint(executablePath)) + { + throw new IOException("The selected executable must not be a symbolic link or other reparse point."); + } + } + + /// + /// Adapts an infrastructure process-family operation to the Core game-launch operation contract. + /// + private sealed class WindowsGameProcessLaunchOperation : IGameProcessLaunchOperation + { + private readonly string _executableName; + + private readonly ILogger _logger; + + private readonly IProcessFamilyLaunchOperation _processFamilyOperation; + private readonly GameLaunchTargetKind _targetKind; + + public WindowsGameProcessLaunchOperation( + GameLaunchTargetKind targetKind, + string executableName, + IProcessFamilyLaunchOperation processFamilyOperation, + ILogger logger) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executableName); + + _targetKind = targetKind; + _executableName = executableName; + _processFamilyOperation = processFamilyOperation ?? + throw new ArgumentNullException(nameof(processFamilyOperation)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _processFamilyOperation.CurrentExecutableNameChanged += ProcessFamilyOperation_CurrentExecutableNameChanged; + Completion = CompleteAsync(); + } + + public string CurrentExecutableName => _processFamilyOperation.CurrentExecutableName; + + public event EventHandler? CurrentExecutableNameChanged; + + public Task Completion { get; } + + public void ForceClose() + { + _processFamilyOperation.ForceClose(); + } + + /// + /// Determines whether the process-family completion satisfies the launch success policy. + /// + private async Task CompleteAsync() + { + try + { + TimeSpan runningDuration = await _processFamilyOperation.Completion.ConfigureAwait(false); + if (_targetKind == GameLaunchTargetKind.GameClient && + runningDuration.TotalMilliseconds < SuccessfulLaunchThresholdMilliseconds) + { + _logger.LogWarning( + "Launch of {ExecutableName} ended after {RunningDurationMs}ms, below the success threshold.", + _executableName, + runningDuration.TotalMilliseconds); + return false; + } + + return true; + } + finally + { + _processFamilyOperation.CurrentExecutableNameChanged -= + ProcessFamilyOperation_CurrentExecutableNameChanged; + } + } + + 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..4c0aae2e --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Services/WindowsProcessFamilyLauncher.cs @@ -0,0 +1,683 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +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. +/// +internal sealed class WindowsProcessFamilyLauncher : IProcessFamilyLauncher +{ + private const int ProcessPollMilliseconds = 100; + + // Some launchers exit before their replacement child becomes visible in a process snapshot. + private const int ProcessHandoffGraceMilliseconds = 500; + + private const uint Th32CsSnapprocess = 0x00000002; + + private static readonly IntPtr _invalidHandleValue = new(-1); + + private readonly ILogger _logger; + + public WindowsProcessFamilyLauncher(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public Task StartAsync( + string executableName, + string arguments, + string workingDirectory, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executableName); + ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory); + + return Task.Run( + () => StartLaunchOperation( + executableName, + arguments ?? string.Empty, + workingDirectory, + cancellationToken), + cancellationToken); + } + + private IProcessFamilyLaunchOperation StartLaunchOperation( + string executableName, + string arguments, + string workingDirectory, + CancellationToken cancellationToken) + { + Process process = StartExecutable(executableName, arguments, workingDirectory); + var processFamily = new ProcessFamilyTracker(process.Id, executableName, _logger); + return new WindowsProcessFamilyLaunchOperation( + processFamily.CurrentExecutableName, + process, + processFamily, + _logger, + cancellationToken); + } + + private static Process StartExecutable( + string executableName, + string arguments, + string workingDirectory) + { + Process process = Process.Start(new ProcessStartInfo + { + FileName = executableName, + Arguments = arguments, + WorkingDirectory = workingDirectory, + UseShellExecute = false + }) ?? throw new InvalidOperationException($"Failed to start {Path.GetFileName(executableName)}."); + return process; + } + + /// + /// Returns when ToolHelp cannot capture a snapshot so tracking can fall back to the root + /// process. + /// + 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() + }; + 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); + } + } + + // ToolHelp provides parent process ids that System.Diagnostics.Process does not expose reliably. + [DllImport("kernel32.dll", SetLastError = true)] + private static extern IntPtr CreateToolhelp32Snapshot(uint flags, uint processId); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] + private static extern bool Process32First(IntPtr snapshotHandle, ref NativeProcessEntry processEntry); + + [DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Auto)] + private static extern bool Process32Next(IntPtr snapshotHandle, ref NativeProcessEntry processEntry); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool CloseHandle(IntPtr handle); + + private static bool IsProcessRunning(int processId) + { + try + { + using var process = Process.GetProcessById(processId); + return !process.HasExited; + } + catch (ArgumentException) + { + return false; + } + catch (InvalidOperationException) + { + return false; + } + } + + private static void ForceCloseProcess(int processId) + { + using var process = Process.GetProcessById(processId); + if (!process.HasExited) + { + process.Kill(true); + } + } + + /// + /// Observes a started Windows process family and exposes a force-close command for it. + /// + private sealed class WindowsProcessFamilyLaunchOperation : IProcessFamilyLaunchOperation + { + private readonly CancellationToken _cancellationToken; + + private readonly string _executableName; + private readonly ILogger _logger; + + private readonly ProcessFamilyTracker _processFamily; + + private readonly Process _rootProcess; + + private readonly Lock _syncRoot = new(); + + private bool _disposed; + + public WindowsProcessFamilyLaunchOperation( + string executableName, + Process rootProcess, + ProcessFamilyTracker processFamily, + ILogger logger, + CancellationToken cancellationToken) + { + 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 CurrentExecutableName => _processFamily.CurrentExecutableName; + + public event EventHandler? CurrentExecutableNameChanged; + + public Task Completion { get; } + + public void ForceClose() + { + _logger.LogInformation( + "Force close requested for launched process family {ExecutableName}.", + _executableName); + _processFamily.ForceClose(); + } + + 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(); + } + } + + private void RaiseCurrentExecutableNameChangedIfNeeded(ref string currentExecutableName) + { + string updatedExecutableName = CurrentExecutableName; + if (string.Equals(updatedExecutableName, currentExecutableName, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + currentExecutableName = updatedExecutableName; + CurrentExecutableNameChanged?.Invoke(this, EventArgs.Empty); + } + + private void DisposeRootProcess() + { + lock (_syncRoot) + { + if (_disposed) + { + return; + } + + _rootProcess.Dispose(); + _disposed = true; + } + } + } + + [StructLayout(LayoutKind.Sequential, CharSet = CharSet.Auto)] + private struct NativeProcessEntry + { + public uint Size; + + public uint UsageCount; + + public uint ProcessId; + + public IntPtr DefaultHeapId; + + public uint ModuleId; + + public uint ThreadCount; + + public uint ParentProcessId; + + public int PriorityClassBase; + + public uint Flags; + + [MarshalAs(UnmanagedType.ByValTStr, SizeConst = 260)] + public string? ExecutableFileName; + } + + /// + /// Tracks descendants across launcher handoffs until the complete Windows process family exits. + /// + internal sealed class ProcessFamilyTracker + { + private readonly Func?> _captureProcessSnapshot; + + private readonly Action _forceCloseProcess; + + private readonly TimeProvider _timeProvider; + + private readonly TimeSpan _handoffGracePeriod; + + private readonly Func _isProcessRunning; + + private readonly ILogger _logger; + + private readonly int _rootProcessId; + + private readonly DateTime _startedAtUtc; + + private readonly Lock _syncRoot = new(); + + // Retain recently exited parents so a replacement child appearing in a later snapshot is still discovered. + private readonly Dictionary _trackedProcesses = []; + + private bool _childProcessObserved; + + private string _currentExecutableName; + + // An empty snapshot after a child was seen may be a handoff gap rather than the end of the family. + private DateTime? _emptyFamilyObservedAtUtc; + + private DateTime _lastObservedRunningAtUtc; + + private int _nextProcessOrder; + + private bool _snapshotFailureLogged; + + public ProcessFamilyTracker( + int rootProcessId, + string rootExecutableName, + ILogger logger) + : this( + rootProcessId, + rootExecutableName, + logger, + TryCaptureProcessSnapshot, + IsProcessRunning, + TimeProvider.System, + TimeSpan.FromMilliseconds(ProcessHandoffGraceMilliseconds), + ForceCloseProcess) + { + } + + internal ProcessFamilyTracker( + int rootProcessId, + string rootExecutableName, + ILogger logger, + Func?> captureProcessSnapshot, + Func isProcessRunning, + TimeProvider timeProvider, + 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)); + _timeProvider = timeProvider ?? throw new ArgumentNullException(nameof(timeProvider)); + _handoffGracePeriod = handoffGracePeriod; + _forceCloseProcess = forceCloseProcess ?? throw new ArgumentNullException(nameof(forceCloseProcess)); + _startedAtUtc = _timeProvider.GetUtcNow().UtcDateTime; + _lastObservedRunningAtUtc = _startedAtUtc; + _currentExecutableName = NormalizeExecutableName(rootExecutableName); + _trackedProcesses.Add( + rootProcessId, + new TrackedProcess(_currentExecutableName, 0, _nextProcessOrder++, _startedAtUtc)); + } + + public TimeSpan RunningDuration => _lastObservedRunningAtUtc - _startedAtUtc; + + public string CurrentExecutableName + { + get + { + lock (_syncRoot) + { + return _currentExecutableName; + } + } + } + + public bool IsRunning() + { + lock (_syncRoot) + { + return IsRunningCore(); + } + } + + public void ForceClose() + { + IReadOnlyList processIds; + lock (_syncRoot) + { + processIds = GetTrackedRunningProcessIds(); + } + + foreach (int processId in processIds) + { + TryForceCloseProcess(processId); + } + } + + private bool IsRunningCore() + { + IReadOnlyList? entries = _captureProcessSnapshot(); + if (entries == null) + { + LogSnapshotFailureOnce(); + return IsRootProcessRunning(); + } + + DateTime nowUtc = _timeProvider.GetUtcNow().UtcDateTime; + ExpireRetiredProcessIds(nowUtc); + TrackDescendants(entries, nowUtc); + + 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; + } + + // A process that remained visible for at least the full handoff window was the stable launched client, not + // a transient launcher. Once it exits, report completion immediately instead of adding another grace delay. + if (!ShouldWaitForHandoff(nowUtc)) + { + return false; + } + + _emptyFamilyObservedAtUtc ??= nowUtc; + return nowUtc - _emptyFamilyObservedAtUtc.Value < _handoffGracePeriod; + } + + private IReadOnlyList GetTrackedRunningProcessIds() + { + IReadOnlyList? entries = _captureProcessSnapshot(); + if (entries == null) + { + LogSnapshotFailureOnce(); + return _trackedProcesses.Keys + .Where(_isProcessRunning) + .ToList(); + } + + TrackDescendants(entries, _timeProvider.GetUtcNow().UtcDateTime); + var runningProcessIds = entries + .Select(entry => entry.ProcessId) + .ToHashSet(); + return _trackedProcesses + .Where(process => !process.Value.RetiredAtUtc.HasValue) + .Select(process => process.Key) + .Where(runningProcessIds.Contains) + .ToList(); + } + + private void TryForceCloseProcess(int processId) + { + try + { + _forceCloseProcess(processId); + _logger.LogInformation("Force closed launched process {ProcessId}.", processId); + } + catch (ArgumentException) + { + _logger.LogDebug( + "Tracked launched process {ProcessId} exited before force close completed.", + processId); + } + catch (InvalidOperationException) + { + _logger.LogDebug( + "Tracked launched process {ProcessId} exited before force close completed.", + processId); + } + catch (Win32Exception exception) + { + _logger.LogWarning(exception, "Failed to force close launched process {ProcessId}.", processId); + } + catch (NotSupportedException exception) + { + _logger.LogWarning(exception, "Failed to force close launched process {ProcessId}.", processId); + } + } + + private void ExpireRetiredProcessIds(DateTime nowUtc) + { + foreach (KeyValuePair retiredProcess in _trackedProcesses.ToList()) + { + if (retiredProcess.Value.RetiredAtUtc is not DateTime retiredAtUtc || + nowUtc - retiredAtUtc < _handoffGracePeriod) + { + continue; + } + + _trackedProcesses.Remove(retiredProcess.Key); + } + } + + private void TrackDescendants(IReadOnlyList entries, DateTime observedAtUtc) + { + bool addedProcess; + do + { + addedProcess = false; + foreach (ProcessSnapshotEntry entry in entries) + { + if (!_trackedProcesses.TryGetValue( + entry.ParentProcessId, + out TrackedProcess? parentProcess) || + _trackedProcesses.ContainsKey(entry.ProcessId)) + { + continue; + } + + addedProcess = true; + _childProcessObserved = true; + var trackedProcess = new TrackedProcess( + NormalizeExecutableName(entry.ExecutableFileName), + parentProcess.Depth + 1, + _nextProcessOrder++, + observedAtUtc); + _trackedProcesses.Add(entry.ProcessId, trackedProcess); + + _logger.LogDebug( + "Tracking launched child process {ProcessId} ({ExecutableName}) for cleanup wait.", + entry.ProcessId, + trackedProcess.ExecutableName); + } + } while (addedProcess); + } + + private bool ShouldWaitForHandoff(DateTime nowUtc) + { + TrackedProcess? lastRetiredChild = _trackedProcesses.Values + .Where(process => process.Depth > 0 && process.RetiredAtUtc.HasValue) + .OrderByDescending(process => process.Depth) + .ThenByDescending(process => process.Order) + .FirstOrDefault(); + return lastRetiredChild is not null && + nowUtc - lastRetiredChild.ObservedAtUtc < _handoffGracePeriod; + } + + private IReadOnlyList UpdateKnownProcessState( + HashSet runningProcessIds, + DateTime nowUtc) + { + var knownRunningProcessIds = new List(); + foreach ((int processId, TrackedProcess process) in _trackedProcesses) + { + if (runningProcessIds.Contains(processId) && !process.RetiredAtUtc.HasValue) + { + knownRunningProcessIds.Add(processId); + continue; + } + + if (!process.RetiredAtUtc.HasValue) + { + process.RetiredAtUtc = nowUtc; + } + } + + return knownRunningProcessIds; + } + + private bool HasActiveTrackedProcess(IReadOnlyList knownRunningProcessIds) + { + if (!_childProcessObserved) + { + return knownRunningProcessIds.Contains(_rootProcessId); + } + + return knownRunningProcessIds.Any(processId => processId != _rootProcessId); + } + + 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 (_trackedProcesses.TryGetValue(currentProcessId.Value, out TrackedProcess? process) && + !string.IsNullOrWhiteSpace(process.ExecutableName)) + { + _currentExecutableName = process.ExecutableName; + } + } + + private int GetProcessDepth(int processId) + { + return _trackedProcesses.TryGetValue(processId, out TrackedProcess? process) + ? process.Depth + : 0; + } + + private int GetProcessOrder(int processId) + { + return _trackedProcesses.TryGetValue(processId, out TrackedProcess? process) + ? process.Order + : 0; + } + + private bool IsRootProcessRunning() + { + if (!_isProcessRunning(_rootProcessId)) + { + return false; + } + + if (_trackedProcesses.TryGetValue(_rootProcessId, out TrackedProcess? process) && + !string.IsNullOrWhiteSpace(process.ExecutableName)) + { + _currentExecutableName = process.ExecutableName; + } + + _lastObservedRunningAtUtc = _timeProvider.GetUtcNow().UtcDateTime; + return true; + } + + private void LogSnapshotFailureOnce() + { + if (_snapshotFailureLogged) + { + return; + } + + _logger.LogWarning( + "Could not inspect launched child processes; falling back to the root launch process only."); + _snapshotFailureLogged = true; + } + + private static string NormalizeExecutableName(string? executableName) + { + return Path.GetFileName(executableName?.Trim() ?? string.Empty); + } + + private sealed class TrackedProcess + { + public TrackedProcess(string executableName, int depth, int order, DateTime observedAtUtc) + { + ExecutableName = executableName; + Depth = depth; + Order = order; + ObservedAtUtc = observedAtUtc; + } + + public string ExecutableName { get; } + + public int Depth { get; } + + public int Order { get; } + + public DateTime ObservedAtUtc { get; } + + public DateTime? RetiredAtUtc { get; set; } + } + } + + internal sealed record ProcessSnapshotEntry( + int ProcessId, + int ParentProcessId, + string ExecutableFileName = ""); +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/DeploymentFilePlanner.cs b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentFilePlanner.cs new file mode 100644 index 00000000..e13e0845 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentFilePlanner.cs @@ -0,0 +1,100 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.IO; +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 deployable package files and applies package precedence. Windows executable and library binaries remain + /// in launcher-owned package storage and are never copied into the user's game directory. + /// + public static IReadOnlyList ResolveDeploymentFiles( + IReadOnlyList packages) + { + ArgumentNullException.ThrowIfNull(packages); + + var filesByTarget = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (DeploymentPackage package in packages.OrderBy(package => package.Precedence)) + { + string packageRoot = LexicalPath.NormalizeFullPath(package.RootDirectory); + if (!Directory.Exists(packageRoot)) + { + throw new DirectoryNotFoundException( + $"Deployment package directory was not found: {package.RootDirectory}"); + } + + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + packageRoot, + "Deployment package directories"); + foreach (string sourcePath in FileSystemPathSafety.GetDirectoryFilesWithNoReparsePoints( + packageRoot, + "Deployment package directories")) + { + string extension = Path.GetExtension(sourcePath); + // Community packages can include their own launchers and tools. Treat those binaries as package-owned + // code, not game-directory payload, so deployment cannot replace executable code in the user's install. + if (string.Equals(extension, ".exe", StringComparison.OrdinalIgnoreCase) || + string.Equals(extension, ".dll", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + string relativePath = DeploymentPathResolver.ToRelativeManifestPath(packageRoot, sourcePath); + string targetRelativePath = LexicalPath.NormalizeRelativePath( + BigFileVariantPath.GetDeploymentPath(relativePath)); + string normalizedTargetPath = DeploymentPathResolver.NormalizeManifestPath(targetRelativePath); + filesByTarget[normalizedTargetPath] = new ResolvedDeploymentFile( + sourcePath, + normalizedTargetPath); + } + } + + return filesByTarget.Values.OrderBy(file => file.TargetRelativePath, StringComparer.OrdinalIgnoreCase).ToList(); + } + + /// + /// Returns missing directories between a game root and target directory in parent-first order. + /// + public static IEnumerable GetDirectoriesToCreate(string gameRoot, string targetDirectory) + { + var directories = new Stack(); + string root = LexicalPath.NormalizeFullPath(gameRoot); + string? current = LexicalPath.NormalizeFullPath(targetDirectory); + while (!string.IsNullOrWhiteSpace(current) && + !LexicalPath.AreEquivalent(current, root) && + !Directory.Exists(current)) + { + directories.Push(current); + current = Directory.GetParent(current)?.FullName; + } + + return directories; + } +} + +internal sealed record ResolvedDeploymentFile( + string SourcePath, + string TargetRelativePath); + +internal sealed record DeploymentPackage +{ + public DeploymentPackage(string rootDirectory, int precedence) + { + ArgumentException.ThrowIfNullOrWhiteSpace(rootDirectory); + + RootDirectory = rootDirectory; + Precedence = precedence; + } + + public string RootDirectory { get; } + + public int Precedence { get; } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/DeploymentPathResolver.cs b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentPathResolver.cs new file mode 100644 index 00000000..b34ea4d9 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentPathResolver.cs @@ -0,0 +1,52 @@ +using System.IO; +using GenLauncherGO.Core.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. + /// + public static string ResolveGamePath(LauncherPaths paths, string relativePath) + { + string normalizedPath = NormalizeManifestPath(relativePath); + string gameRoot = LexicalPath.NormalizeFullPath(paths.GameDirectory); + string candidatePath = LexicalPath.ResolvePath(gameRoot, normalizedPath); + string ownedGameDataRoot = LexicalPath.NormalizeFullPath(paths.OwnedGameDataDirectory); + + if (!LexicalPath.IsPathInDirectory(candidatePath, gameRoot) || + LexicalPath.IsPathInDirectory(candidatePath, ownedGameDataRoot)) + { + throw new InvalidDataException($"Deployment target path '{relativePath}' is outside the game directory."); + } + + return candidatePath; + } + + public static string NormalizeManifestPath(string relativePath) + { + return ManifestPathResolver.NormalizeForDeploymentManifest(relativePath); + } + + public static string ToRelativeManifestPath(string rootDirectory, string path) + { + return NormalizeManifestPath(LexicalPath.GetRelativePath(rootDirectory, path)); + } + + /// + /// Resolves a deployment-state-relative path. + /// + public static string ResolveDeploymentStatePath(string deploymentDirectory, string relativePath) + { + return LexicalPath.ResolveContainedPath( + deploymentDirectory, + NormalizeManifestPath(relativePath), + $"Deployment state path '{relativePath}' is outside the deployment directory."); + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/DeploymentResult.cs b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentResult.cs new file mode 100644 index 00000000..1a841cbb --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentResult.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace GenLauncherGO.Infrastructure.Launching.Support; + +/// +/// Carries deployment failure details to the launch-preparation boundary. +/// +internal sealed record DeploymentResult +{ + private DeploymentResult(IReadOnlyList failures) + { + Failures = failures.ToArray(); + } + + public bool Succeeded => Failures.Count == 0; + + public IReadOnlyList Failures { get; } + + public static DeploymentResult Success() + { + return new DeploymentResult(Array.Empty()); + } + + public static DeploymentResult Failure(IReadOnlyList failures) + { + ArgumentNullException.ThrowIfNull(failures); + if (failures.Count == 0) + { + throw new ArgumentException("At least one deployment failure is required.", nameof(failures)); + } + + return new DeploymentResult(failures); + } +} + +internal sealed record DeploymentFailure(DeploymentFailureKind Kind, string Path, string Message); + +internal enum DeploymentFailureKind +{ + FileSystem, + Manifest +} + +/// +/// Records whether cleanup must account for a deployed hard link or copy. +/// +internal enum DeploymentMethod +{ + HardLink, + Copy +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/DeploymentStateStore.cs b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentStateStore.cs new file mode 100644 index 00000000..2554485b --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/DeploymentStateStore.cs @@ -0,0 +1,730 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Persistence.Services; +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 +{ + public const string BackupsDirectoryName = "Backups"; + + internal const int CurrentSchemaVersion = 2; + + private const string ActiveManifestFileName = "active.json"; + + private const string JournalFileName = "journal.jsonl"; + + private const string LockFileName = "deployment.lock"; + + private static readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web) + { + // Schema-v2 manifests may contain retired reporting fields that are irrelevant to safe recovery. + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip, + WriteIndented = true + }; + + private static readonly JsonSerializerOptions _journalJsonOptions = new(JsonSerializerDefaults.Web) + { + UnmappedMemberHandling = JsonUnmappedMemberHandling.Skip + }; + + private readonly ILogger _logger; + + public DeploymentStateStore(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + 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); + } + + /// + /// Holds an exclusive file lock so deployment preparation, cleanup, and recovery cannot overlap. + /// + public static FileStream AcquireDeploymentLock(LauncherPaths paths) + { + DeploymentStatePaths deploymentPaths = CreatePaths(paths, string.Empty); + OwnedDirectoryTree.EnsureExists(paths.OwnedGameDataDirectory, deploymentPaths.DeploymentDirectory); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + deploymentPaths.LockPath, + "Deployment lock paths"); + + return new FileStream( + deploymentPaths.LockPath, + FileMode.OpenOrCreate, + FileAccess.ReadWrite, + FileShare.None); + } + + /// + /// Commits the completed deployment manifest with atomic replacement semantics. + /// + public static void WriteManifest(string manifestPath, DeploymentManifestDocument manifest) + { + new AtomicFileWriter().WriteText(manifestPath, JsonSerializer.Serialize(manifest, _jsonOptions)); + } + + /// + /// Deletes persisted active deployment state after cleanup or recovery. + /// + public static void DeleteDeploymentStateFiles(DeploymentStatePaths paths) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + paths.ActiveManifestPath, + "Deployment manifest paths"); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + paths.JournalPath, + "Deployment journal paths"); + + if (File.Exists(paths.ActiveManifestPath)) + { + File.Delete(paths.ActiveManifestPath); + } + + if (File.Exists(paths.JournalPath)) + { + File.Delete(paths.JournalPath); + } + } + + /// + /// Opens the append-only journal once for a deployment operation. + /// + public static FileStream OpenJournal(string journalPath) + { + string journalDirectory = Path.GetDirectoryName(journalPath) + ?? throw new InvalidOperationException( + "Deployment journal paths must have a parent directory."); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + journalDirectory, + "Deployment journal paths"); + Directory.CreateDirectory(journalDirectory); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + journalPath, + "Deployment journal paths"); + + return new FileStream( + journalPath, + FileMode.Append, + FileAccess.Write, + FileShare.Read, + 4096, + FileOptions.None); + } + + /// + /// Durably appends one standalone recovery record. + /// + public static void AppendJournal(string journalPath, DeploymentJournalRecord record) + { + using FileStream journal = OpenJournal(journalPath); + AppendJournalDurably(journal, record); + } + + /// + /// Appends a recovery record. A later durable intent flushes earlier completion records in order. + /// + public static void AppendJournal(FileStream journal, DeploymentJournalRecord record) + { + ArgumentNullException.ThrowIfNull(journal); + ArgumentNullException.ThrowIfNull(record); + + byte[] bytes = Encoding.UTF8.GetBytes( + JsonSerializer.Serialize(record, _journalJsonOptions) + Environment.NewLine); + journal.Write(bytes, 0, bytes.Length); + } + + /// + /// Appends and commits an intent before its recoverable file-system mutation runs. + /// + public static void AppendJournalDurably(FileStream journal, DeploymentJournalRecord record) + { + AppendJournal(journal, record); + FlushJournal(journal); + } + + /// + /// Commits all appended recovery records through the storage device. + /// + public static void FlushJournal(FileStream journal) + { + ArgumentNullException.ThrowIfNull(journal); + journal.Flush(true); + } + + /// + /// Reads the active manifest or reconstructs it from the deployment journal. + /// + public DeploymentManifestDocument? ReadManifestOrJournal( + LauncherPaths launcherPaths, + DeploymentStatePaths paths) + { + DeploymentManifestDocument? manifest = TryReadManifest(paths.ActiveManifestPath, out Exception? readException); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + paths.JournalPath, + "Deployment journal paths"); + if (File.Exists(paths.JournalPath)) + { + DeploymentManifestDocument? journalManifest = RebuildManifestFromJournal(paths); + if (journalManifest is not null) + { + ValidateGameRoot(launcherPaths, journalManifest); + 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); + } + + if (manifest is not null) + { + ValidateGameRoot(launcherPaths, manifest); + } + + return manifest; + } + + if (manifest is not null) + { + ValidateGameRoot(launcherPaths, manifest); + 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; + } + + /// + /// Tries to read a completed deployment manifest without preventing journal fallback. + /// + private DeploymentManifestDocument? TryReadManifest(string manifestPath, out Exception? readException) + { + readException = null; + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + manifestPath, + "Deployment manifest paths"); + 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 exception) when (exception is IOException or JsonException or NotSupportedException) + { + readException = exception; + _logger.LogWarning(exception, "Deployment manifest could not be read; journal recovery will be attempted."); + return null; + } + } + + /// + /// Rebuilds the effective deployment state from intent and completion records after an interrupted mutation. + /// + private DeploymentManifestDocument? RebuildManifestFromJournal(DeploymentStatePaths paths) + { + var filesByTarget = new Dictionary(StringComparer.OrdinalIgnoreCase); + var backupsByTarget = new Dictionary(StringComparer.OrdinalIgnoreCase); + var backupStartsByTarget = new Dictionary(StringComparer.OrdinalIgnoreCase); + var directories = new HashSet(StringComparer.OrdinalIgnoreCase); + bool sawDeploymentStateRecord = false; + string? deploymentId = null; + string? gameRoot = null; + string? gameRootIdentity = null; + SupportedGame game = SupportedGame.Unknown; + + foreach (string line in File.ReadLines(paths.JournalPath)) + { + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + DeploymentJournalRecord? record; + try + { + record = JsonSerializer.Deserialize(line, _journalJsonOptions); + } + catch (JsonException exception) + { + _logger.LogWarning(exception, "Skipped unreadable deployment journal record."); + continue; + } + + if (record is null) + { + continue; + } + + if (string.Equals(record.Action, DeploymentJournalRecord.DeploymentStartedAction, StringComparison.Ordinal)) + { + sawDeploymentStateRecord = true; + deploymentId = record.DeploymentId; + gameRoot = record.GameRoot; + gameRootIdentity = record.GameRootIdentity; + game = record.Game; + continue; + } + + if (string.IsNullOrWhiteSpace(record.TargetRelativePath)) + { + _logger.LogWarning("Skipped deployment journal record without a target path."); + continue; + } + + switch (record.Action) + { + case DeploymentJournalRecord.DirectoryCreatedAction: + sawDeploymentStateRecord = true; + directories.Add(record.TargetRelativePath); + break; + + case DeploymentJournalRecord.FileBackupStartedAction: + sawDeploymentStateRecord = true; + if (!string.IsNullOrWhiteSpace(record.BackupRelativePath)) + { + var backup = new DeploymentBackupDocument( + record.BackupRelativePath, + record.BackupFingerprint, + record.StagingRelativePath); + backupStartsByTarget[record.TargetRelativePath] = backup; + backupsByTarget[record.TargetRelativePath] = backup; + } + + break; + + case DeploymentJournalRecord.FileBackedUpAction: + { + sawDeploymentStateRecord = true; + var backup = new DeploymentBackupDocument( + record.BackupRelativePath ?? string.Empty, + record.BackupFingerprint, + record.StagingRelativePath); + backupsByTarget[record.TargetRelativePath] = backup; + filesByTarget[record.TargetRelativePath] = new DeploymentFileDocument( + record.TargetRelativePath, + DeploymentMethod.Copy, + record.BackupRelativePath, + null, + record.BackupFingerprint, + null, + record.StagingRelativePath); + break; + } + + case DeploymentJournalRecord.FileDeploymentStartedAction: + { + sawDeploymentStateRecord = true; + string targetRelativePath = record.TargetRelativePath; + backupsByTarget.TryGetValue(targetRelativePath, out DeploymentBackupDocument? backup); + filesByTarget[targetRelativePath] = new DeploymentFileDocument( + targetRelativePath, + // The hard-link attempt happens after this intent record. Treat an interrupted operation as a + // potential hard link so recovery never clears a shared ReadOnly attribute through the target. + DeploymentMethod.HardLink, + record.BackupRelativePath ?? backup?.RelativePath, + record.DeployedFingerprint, + record.BackupFingerprint ?? backup?.Fingerprint, + record.StagingRelativePath, + backup?.StagingRelativePath); + break; + } + + case DeploymentJournalRecord.FileDeployedAction: + { + sawDeploymentStateRecord = true; + string targetRelativePath = record.TargetRelativePath; + backupsByTarget.TryGetValue(targetRelativePath, out DeploymentBackupDocument? backup); + filesByTarget[targetRelativePath] = new DeploymentFileDocument( + targetRelativePath, + record.Method ?? DeploymentMethod.Copy, + record.BackupRelativePath ?? backup?.RelativePath, + record.DeployedFingerprint, + record.BackupFingerprint ?? backup?.Fingerprint, + record.StagingRelativePath, + backup?.StagingRelativePath, + DeployedFileIdentity: record.DeployedFileIdentity); + break; + } + + case DeploymentJournalRecord.FileCleanupDeleteCompletedAction: + case DeploymentJournalRecord.FileCleanupRestoredAction: + sawDeploymentStateRecord = true; + filesByTarget.Remove(record.TargetRelativePath); + break; + + case DeploymentJournalRecord.FileCleanupRestoreStartedAction: + sawDeploymentStateRecord = true; + if (filesByTarget.TryGetValue( + record.TargetRelativePath, + out DeploymentFileDocument? restoringFile)) + { + filesByTarget[record.TargetRelativePath] = restoringFile with + { + RestoreStagingRelativePath = record.StagingRelativePath + }; + } + + break; + } + } + + foreach (KeyValuePair backupStart in backupStartsByTarget) + { + if (filesByTarget.ContainsKey(backupStart.Key)) + { + continue; + } + + string backupPath = DeploymentPathResolver.ResolveDeploymentStatePath( + paths.DeploymentDirectory, + backupStart.Value.RelativePath); + if (!File.Exists(backupPath)) + { + DeleteIncompleteLauncherBackup(paths, backupStart.Value.StagingRelativePath); + continue; + } + + filesByTarget[backupStart.Key] = new DeploymentFileDocument( + backupStart.Key, + DeploymentMethod.Copy, + backupStart.Value.RelativePath, + null, + backupStart.Value.Fingerprint, + null, + backupStart.Value.StagingRelativePath); + } + + if (filesByTarget.Count == 0 && directories.Count == 0 && !sawDeploymentStateRecord) + { + return null; + } + + return new DeploymentManifestDocument( + CurrentSchemaVersion, + deploymentId ?? "recovered", + filesByTarget.Values.ToList(), + directories.OrderByDescending(path => path.Length).ToList(), + gameRoot, + gameRootIdentity, + game); + } + + private static void DeleteIncompleteLauncherBackup( + DeploymentStatePaths paths, + string? stagingRelativePath) + { + if (string.IsNullOrWhiteSpace(stagingRelativePath)) + { + return; + } + + string stagingPath = DeploymentPathResolver.ResolveDeploymentStatePath( + paths.DeploymentDirectory, + stagingRelativePath); + stagingPath = FileSystemPathSafety.ResolveOwnedSubpath( + paths.DeploymentDirectory, + stagingPath, + "Deployment backup staging paths", + "the deployment directory"); + if (File.Exists(stagingPath)) + { + FileAttributes attributes = File.GetAttributes(stagingPath); + if ((attributes & FileAttributes.ReadOnly) != 0) + { + File.SetAttributes(stagingPath, attributes & ~FileAttributes.ReadOnly); + } + + File.Delete(stagingPath); + } + } + + /// + /// Refuses to replay durable state against a different game installation. + /// + private static void ValidateGameRoot(LauncherPaths paths, DeploymentManifestDocument manifest) + { + if (manifest.SchemaVersion != CurrentSchemaVersion) + { + throw new InvalidDataException("The deployment state schema is not supported."); + } + + if (manifest.Game != paths.Game) + { + throw new InvalidDataException("Deployment state belongs to a different supported game."); + } + + if (string.IsNullOrWhiteSpace(manifest.GameRoot) || + !LexicalPath.AreEquivalent( + manifest.GameRoot, + PhysicalDirectoryPath.ResolveExisting(paths.GameDirectory))) + { + throw new InvalidDataException("Deployment state belongs to a different game directory."); + } + + if (string.IsNullOrWhiteSpace(manifest.GameRootIdentity) || + !string.Equals( + manifest.GameRootIdentity, + GetGameRootIdentity(paths.GameDirectory), + StringComparison.Ordinal)) + { + throw new InvalidDataException("The game directory changed after deployment state was recorded."); + } + } + + internal static string GetGameRootIdentity(string gameDirectory) + { + return FormatIdentity(PhysicalDirectoryPath.GetIdentity(gameDirectory)); + } + + internal static string GetFileIdentity(string filePath) + { + return FormatIdentity(PhysicalDirectoryPath.GetFileIdentity(filePath)); + } + + private static string FormatIdentity(PhysicalFileSystemIdentity identity) + { + return $"{identity.VolumeSerialNumber:X8}:{identity.FileIndex:X16}"; + } +} + +internal sealed record DeploymentStatePaths( + string DeploymentDirectory, + string ActiveManifestPath, + string JournalPath, + string LockPath, + string BackupDirectory); + +/// +/// Defines the versioned manifest persisted for deployment cleanup and recovery. +/// +internal sealed record DeploymentManifestDocument( + int SchemaVersion, + string DeploymentId, + IReadOnlyList Files, + IReadOnlyList CreatedDirectories, + string? GameRoot = null, + string? GameRootIdentity = null, + SupportedGame Game = SupportedGame.Unknown); + +/// +/// Defines one file-system mutation persisted in a deployment manifest. +/// +internal sealed record DeploymentFileDocument( + string TargetRelativePath, + DeploymentMethod Method, + string? BackupRelativePath, + DeploymentFileFingerprint? DeployedFingerprint = null, + DeploymentFileFingerprint? BackupFingerprint = null, + string? StagingRelativePath = null, + string? BackupStagingRelativePath = null, + string? RestoreStagingRelativePath = null, + string? DeployedFileIdentity = null); + +/// +/// Identifies exact file bytes that a deployment transaction may safely remove or replace. +/// +internal sealed record DeploymentFileFingerprint(long Length, string Sha256); + +internal sealed record DeploymentBackupDocument( + string RelativePath, + DeploymentFileFingerprint? Fingerprint, + string? StagingRelativePath); + +/// +/// Defines one intent or completion record in the append-only recovery journal. +/// +internal sealed record DeploymentJournalRecord( + string Action, + string? TargetRelativePath, + string? BackupRelativePath, + DeploymentMethod? Method, + string? DeploymentId = null, + string? GameRoot = null, + string? GameRootIdentity = null, + DeploymentFileFingerprint? DeployedFingerprint = null, + DeploymentFileFingerprint? BackupFingerprint = null, + string? StagingRelativePath = null, + SupportedGame Game = SupportedGame.Unknown, + string? DeployedFileIdentity = null) +{ + public const string DeploymentStartedAction = "deployment-started"; + + public const string DirectoryCreatedAction = "directory-created"; + + public const string FileBackupStartedAction = "file-backup-started"; + + public const string FileBackedUpAction = "file-backed-up"; + + public const string FileDeploymentStartedAction = "file-deployment-started"; + + public const string FileDeployedAction = "file-deployed"; + + public const string FileCleanupDeleteCompletedAction = "file-cleanup-delete-completed"; + + public const string FileCleanupRestoreStartedAction = "file-cleanup-restore-started"; + + public const string FileCleanupRestoredAction = "file-cleanup-restored"; + + public static DeploymentJournalRecord DeploymentStarted( + string deploymentId, + string gameRoot, + string gameRootIdentity, + SupportedGame game) + { + return new DeploymentJournalRecord( + DeploymentStartedAction, + null, + null, + null, + deploymentId, + gameRoot, + gameRootIdentity, + Game: game); + } + + public static DeploymentJournalRecord DirectoryCreated(string targetRelativePath) + { + return new DeploymentJournalRecord(DirectoryCreatedAction, targetRelativePath, null, null); + } + + public static DeploymentJournalRecord FileBackupStarted( + string targetRelativePath, + string backupRelativePath, + string stagingRelativePath) + { + return new DeploymentJournalRecord( + FileBackupStartedAction, + targetRelativePath, + backupRelativePath, + null, + StagingRelativePath: stagingRelativePath); + } + + public static DeploymentJournalRecord FileBackedUp( + string targetRelativePath, + string backupRelativePath, + DeploymentFileFingerprint backupFingerprint, + string stagingRelativePath) + { + return new DeploymentJournalRecord( + FileBackedUpAction, + targetRelativePath, + backupRelativePath, + null, + BackupFingerprint: backupFingerprint, + StagingRelativePath: stagingRelativePath); + } + + public static DeploymentJournalRecord FileDeploymentStarted( + string targetRelativePath, + string? backupRelativePath, + DeploymentFileFingerprint deployedFingerprint, + DeploymentFileFingerprint? backupFingerprint, + string stagingRelativePath) + { + return new DeploymentJournalRecord( + FileDeploymentStartedAction, + targetRelativePath, + backupRelativePath, + null, + DeployedFingerprint: deployedFingerprint, + BackupFingerprint: backupFingerprint, + StagingRelativePath: stagingRelativePath); + } + + public static DeploymentJournalRecord FileDeployed( + string targetRelativePath, + DeploymentMethod method, + string? backupRelativePath, + DeploymentFileFingerprint deployedFingerprint, + DeploymentFileFingerprint? backupFingerprint, + string stagingRelativePath, + string? deployedFileIdentity = null) + { + return new DeploymentJournalRecord( + FileDeployedAction, + targetRelativePath, + backupRelativePath, + method, + DeployedFingerprint: deployedFingerprint, + BackupFingerprint: backupFingerprint, + StagingRelativePath: stagingRelativePath, + DeployedFileIdentity: deployedFileIdentity); + } + + public static DeploymentJournalRecord FileCleanupDeleted(string targetRelativePath) + { + return new DeploymentJournalRecord( + FileCleanupDeleteCompletedAction, + targetRelativePath, + null, + null); + } + + public static DeploymentJournalRecord FileCleanupRestoreStarted( + string targetRelativePath, + string backupRelativePath, + string stagingRelativePath) + { + return new DeploymentJournalRecord( + FileCleanupRestoreStartedAction, + targetRelativePath, + backupRelativePath, + null, + StagingRelativePath: stagingRelativePath); + } + + public static DeploymentJournalRecord FileCleanupRestored(string targetRelativePath, string backupRelativePath) + { + return new DeploymentJournalRecord( + FileCleanupRestoredAction, + targetRelativePath, + backupRelativePath, + null); + } +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/IHardLinkCreator.cs b/GenLauncherGO.Infrastructure/Launching/Support/IHardLinkCreator.cs new file mode 100644 index 00000000..47fcb3a1 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/IHardLinkCreator.cs @@ -0,0 +1,17 @@ +namespace GenLauncherGO.Infrastructure.Launching.Support; + +/// +/// Queries hard-link eligibility and creates hard links between installed package files and game-directory targets. +/// +internal interface IHardLinkCreator +{ + /// + /// Determines whether two paths reside on the same physical volume and can use atomic moves or hard links. + /// + bool ArePathsOnSameVolume(string firstPath, string secondPath); + + /// + /// Attempts to create a hard link. + /// + 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..4c8d2b61 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/IProcessFamilyLaunchOperation.cs @@ -0,0 +1,30 @@ +using System; +using System.Threading.Tasks; + +namespace GenLauncherGO.Infrastructure.Launching.Support; + +/// +/// Represents a launched Windows process family that can be observed and force closed. +/// +internal interface IProcessFamilyLaunchOperation +{ + /// + /// Gets the executable name for the currently running tracked process. + /// + string CurrentExecutableName { get; } + + /// + /// Gets the task that completes when every tracked process in the launched process family has exited. + /// + Task Completion { get; } + + /// + /// Occurs when changes. + /// + event EventHandler? CurrentExecutableNameChanged; + + /// + /// 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..ca8c5163 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/IProcessFamilyLauncher.cs @@ -0,0 +1,19 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Infrastructure.Launching.Support; + +/// +/// Starts a process and waits until the launched process family has exited. +/// +internal interface IProcessFamilyLauncher +{ + /// + /// Starts the executable and returns an operation that tracks the launched process family. + /// + Task StartAsync( + string executableName, + string arguments, + string workingDirectory, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Launching/Support/WindowsHardLinkCreator.cs b/GenLauncherGO.Infrastructure/Launching/Support/WindowsHardLinkCreator.cs new file mode 100644 index 00000000..bdde8e02 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Launching/Support/WindowsHardLinkCreator.cs @@ -0,0 +1,64 @@ +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using GenLauncherGO.Infrastructure.Common; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Launching.Support; + +/// +/// Queries Windows volume identity and creates hard links through the Windows file-system API. +/// +internal sealed class WindowsHardLinkCreator : IHardLinkCreator +{ + private readonly ILogger _logger; + + public WindowsHardLinkCreator(ILogger? logger = null) + { + _logger = logger ?? NullLogger.Instance; + } + + public bool ArePathsOnSameVolume(string firstPath, string secondPath) + { + string firstDirectory = ResolveParentDirectory(firstPath); + string secondDirectory = ResolveParentDirectory(secondPath); + return PhysicalDirectoryPath.GetIdentity(firstDirectory).VolumeSerialNumber == + PhysicalDirectoryPath.GetIdentity(secondDirectory).VolumeSerialNumber; + } + + public bool TryCreateHardLink(string targetPath, string sourcePath) + { + bool created = CreateHardLink( + PhysicalDirectoryPath.ToExtendedLengthPath(targetPath), + PhysicalDirectoryPath.ToExtendedLengthPath(sourcePath), + 0); + if (!created) + { + int errorCode = Marshal.GetLastWin32Error(); + _logger.LogDebug( + "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; + } + + private static string ResolveParentDirectory(string path) + { + return Directory.Exists(path) + ? path + : Path.GetDirectoryName(path) + ?? throw new InvalidOperationException("Deployment file paths must have a parent directory."); + } + + [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..a2d5fb5c --- /dev/null +++ b/GenLauncherGO.Infrastructure/Logging/LoggingServiceCollectionExtensions.cs @@ -0,0 +1,93 @@ +using System; +using System.Globalization; +using System.IO; +using GenLauncherGO.Core.IO; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Serilog; +using Serilog.Events; +using ILogger = Serilog.ILogger; + +namespace GenLauncherGO.Infrastructure.Logging; + +public static class LoggingServiceCollectionExtensions +{ + private const int RetainedLogFileCount = 14; + + private const string LogFilePrefix = "GenLauncherGO"; + + /// + /// Registers the standard GenLauncherGO logging pipeline with rolling file logs. + /// + public static IServiceCollection AddGenLauncherGoLogging( + this IServiceCollection services, + string logDirectory, + bool enableDiagnosticLogging = false) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(logDirectory); + + Directory.CreateDirectory(logDirectory); + + string logFilePath = CreateLogFilePath(logDirectory); + PruneOldLogFiles(logDirectory, logFilePath); + LogEventLevel minimumLevel = enableDiagnosticLogging + ? LogEventLevel.Debug + : LogEventLevel.Information; + ILogger logger = new LoggerConfiguration() + .MinimumLevel.Is(minimumLevel) + .MinimumLevel.Override("Microsoft", LogEventLevel.Warning) + .Enrich.FromLogContext() + .WriteTo.File( + new SensitiveDataRedactingTextFormatter(), + logFilePath, + shared: false) + .CreateLogger(); + + services.AddLogging(builder => + { + builder.ClearProviders(); + builder.AddSerilog(logger, true); + }); + + return services; + } + + 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; + } + + 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)); + + int retainedCount = 1; + foreach (FileInfo logFile in logFiles) + { + if (LexicalPath.AreEquivalent(logFile.FullName, activeLogFilePath)) + { + 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..b50fa24f --- /dev/null +++ b/GenLauncherGO.Infrastructure/Logging/SensitiveDataRedactingTextFormatter.cs @@ -0,0 +1,99 @@ +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 partial class SensitiveDataRedactingTextFormatter : ITextFormatter +{ + /// + /// Replaces source-file paths emitted by exception stack traces. + /// + private static readonly Regex _stackTraceSourcePathPattern = StackTraceSourcePathPattern(); + + /// + /// Replaces UNC paths before drive-letter paths so adjacent path values cannot consume the UNC introducer. + /// + private static readonly Regex _uncWindowsPathPattern = UncWindowsPathPattern(); + + /// + /// Replaces absolute drive-letter paths using either Windows path separator. + /// + private static readonly Regex _absoluteDriveWindowsPathPattern = AbsoluteDriveWindowsPathPattern(); + + /// + /// Replaces URI user-info credentials. + /// + private static readonly Regex _uriUserInfoPattern = UriUserInfoPattern(); + + /// + /// Replaces common token, key, credential, secret, signature, and password query-string values. + /// + private static readonly Regex _sensitiveQueryValuePattern = SensitiveQueryValuePattern(); + + 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())); + } + } + + 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() + }; + } + + 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]"); + redacted = _uncWindowsPathPattern.Replace(redacted, "[local path]"); + return _absoluteDriveWindowsPathPattern.Replace(redacted, "[local path]"); + } + + [GeneratedRegex(@"\sin\s[A-Za-z]:\\[^\r\n]*:line\s(?\d+)", RegexOptions.Compiled)] + private static partial Regex StackTraceSourcePathPattern(); + + [GeneratedRegex(@"\\\\[^\\/\r\n:*?""<>|]+[\\/][^\\/\r\n:*?""<>|]+(?:[\\/][^\\/\r\n:*?""<>|]*)*", + RegexOptions.Compiled)] + private static partial Regex UncWindowsPathPattern(); + + [GeneratedRegex(@"(?|]+[\\/])*[^\\/\r\n:*?""<>|]*", + RegexOptions.Compiled)] + private static partial Regex AbsoluteDriveWindowsPathPattern(); + + [GeneratedRegex(@"(?i)(?\b[a-z][a-z0-9+.-]*://)[^/\s?#@]+@", RegexOptions.Compiled)] + private static partial Regex UriUserInfoPattern(); + + [GeneratedRegex( + @"(?i)(?[?&](?:access[_-]?token|api[_-]?key|credential|secret|token|session[_-]?token|" + + @"security[_-]?token|password|signature|sig|x-amz-(?:credential|signature|security-token))=)[^&\s]+", + RegexOptions.Compiled)] + private static partial Regex SensitiveQueryValuePattern(); +} diff --git a/GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherContentStateStore.cs b/GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherContentStateStore.cs new file mode 100644 index 00000000..cb1f2415 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Contracts/ILauncherContentStateStore.cs @@ -0,0 +1,20 @@ +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Contracts; + +/// +/// Loads and saves the compact launcher content state. +/// +internal interface ILauncherContentStateStore +{ + /// + /// Loads persisted launcher content state, returning an empty state when none can be loaded. + /// + LauncherContentState Load(LauncherPaths paths); + + /// + /// Saves launcher content state. + /// + void Save(LauncherPaths paths, LauncherContentState state); +} diff --git a/GenLauncherGO.Infrastructure/Mods/Contracts/ILocalLauncherContentService.cs b/GenLauncherGO.Infrastructure/Mods/Contracts/ILocalLauncherContentService.cs new file mode 100644 index 00000000..c9c5946f --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Contracts/ILocalLauncherContentService.cs @@ -0,0 +1,43 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Infrastructure.Mods.Contracts; + +/// +/// Provides local file-system operations for launcher-managed content. +/// +internal interface ILocalLauncherContentService +{ + /// + /// Finds installed content versions under the launcher-owned mods directory. + /// + IReadOnlyList FindInstalledVersions(LauncherPaths paths); + + /// + /// Removes empty package-recovery directories that no longer contain a durable backup. + /// + void DeleteEmptyPackageBackupDirectories(LauncherPaths paths); + + /// + /// Deletes an installed content version from the launcher-owned mods directory. + /// + void DeleteVersion( + LauncherPaths paths, + LauncherContentKey contentKey); + + /// + /// Deletes all installed content files for a content card from the launcher-owned mods directory. + /// + void DeleteContent( + LauncherPaths paths, + LauncherContentKey contentKey); + + /// + /// Deletes the launcher-owned image cache when no content card still references the same content name. + /// + void DeleteImagesIfUnused( + LauncherPaths paths, + LauncherContentKey contentKey, + LauncherData launcherData); +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/LauncherContentEntryState.cs b/GenLauncherGO.Infrastructure/Mods/Models/LauncherContentEntryState.cs new file mode 100644 index 00000000..1e478962 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/LauncherContentEntryState.cs @@ -0,0 +1,28 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Stores local state for one launcher content card without remote manifest metadata. +/// +internal sealed class LauncherContentEntryState +{ + public ModificationType ModificationType { get; set; } + + public string Name { get; set; } = string.Empty; + + public string DependenceName { get; set; } = string.Empty; + + public bool Installed { get; set; } + + public bool IsSelected { get; set; } + + public int NumberInList { get; set; } + + /// + /// The property name is the existing on-disk YAML key and must remain compatible with saved launcher data. + /// + public List ModificationVersions { get; set; } = + []; +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/LauncherContentState.cs b/GenLauncherGO.Infrastructure/Mods/Models/LauncherContentState.cs new file mode 100644 index 00000000..4aa8d396 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/LauncherContentState.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Stores compact launcher content state that is safe to persist locally. +/// +internal sealed class LauncherContentState +{ + public List Addons { get; set; } = []; + + public List Modifications { get; set; } = []; + + public List Patches { get; set; } = []; +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/LauncherContentVersionState.cs b/GenLauncherGO.Infrastructure/Mods/Models/LauncherContentVersionState.cs new file mode 100644 index 00000000..d5a9b731 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/LauncherContentVersionState.cs @@ -0,0 +1,34 @@ +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Stores local state for one launcher content version without remote manifest metadata. +/// +internal sealed class LauncherContentVersionState +{ + public ModificationType ModificationType { get; set; } + + public string Name { get; set; } = string.Empty; + + public string Version { get; set; } = string.Empty; + + public string DependenceName { get; set; } = string.Empty; + + public bool Installed { get; set; } + + public bool IsSelected { get; set; } + + /// + /// Gets or sets whether partial download content was deliberately kept for resuming in a later session. + /// + public bool DownloadSuspended { get; set; } + + /// + /// Gets or sets the progress the suspended download had reached, so its bar can be restored. + /// + public double SuspendedProgressPercentage { get; set; } + + public ContentSourceKind ContentSourceKind { get; set; } = ContentSourceKind.UnknownLegacy; +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/LegacyCatalogAdvertisingReference.cs b/GenLauncherGO.Infrastructure/Mods/Models/LegacyCatalogAdvertisingReference.cs new file mode 100644 index 00000000..44e09105 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/LegacyCatalogAdvertisingReference.cs @@ -0,0 +1,15 @@ +using System.Collections.Generic; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Represents one advertising entry in the legacy remote catalog document. +/// +internal sealed class LegacyCatalogAdvertisingReference +{ + public string ModName { get; set; } = string.Empty; + + public string ModLink { get; set; } = string.Empty; + + public List ImagesData { get; set; } = []; +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/LegacyCatalogModificationReference.cs b/GenLauncherGO.Infrastructure/Mods/Models/LegacyCatalogModificationReference.cs new file mode 100644 index 00000000..d1ac8157 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/LegacyCatalogModificationReference.cs @@ -0,0 +1,17 @@ +using System.Collections.Generic; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Represents one modification and its child-manifest links in the legacy remote catalog document. +/// +internal sealed class LegacyCatalogModificationReference +{ + public string ModName { get; set; } = string.Empty; + + public string ModLink { get; set; } = string.Empty; + + public List ModPatches { get; set; } = []; + + public List ModAddons { get; set; } = []; +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/LegacyContentManifest.cs b/GenLauncherGO.Infrastructure/Mods/Models/LegacyContentManifest.cs new file mode 100644 index 00000000..a5507f94 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/LegacyContentManifest.cs @@ -0,0 +1,53 @@ +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Represents one content manifest using the exact property names accepted from the legacy remote backend. +/// +internal sealed class LegacyContentManifest +{ + public ModificationType ModificationType { get; set; } + + public string Name { get; set; } = string.Empty; + + public string Version { get; set; } = string.Empty; + + public string SimpleDownloadLink { get; set; } = string.Empty; + + // ReSharper disable once InconsistentNaming + public string UIImageSourceLink { get; set; } = string.Empty; + + public string DiscordLink { get; set; } = string.Empty; + + // ReSharper disable once InconsistentNaming + public string ModDBLink { get; set; } = string.Empty; + + public string NewsLink { get; set; } = string.Empty; + + public string DependenceName { get; set; } = string.Empty; + + public string S3HostLink { get; set; } = string.Empty; + + public string S3BucketName { get; set; } = string.Empty; + + public string S3FolderName { get; set; } = string.Empty; + + public string S3HostPublicKey { get; set; } = string.Empty; + + public string S3HostSecretKey { get; set; } = string.Empty; + + public string NetworkInfo { get; set; } = string.Empty; + + public bool Deprecated { get; set; } + + public string SupportLink { get; set; } = string.Empty; + + /// + /// Gets or sets the optional palette this modification asks the launcher to wear while it is selected. + /// + public LegacyContentThemeManifest? ColorsInformation { get; set; } + + public ContentSourceKind ContentSourceKind { get; set; } = ContentSourceKind.UnknownLegacy; +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/LegacyContentThemeManifest.cs b/GenLauncherGO.Infrastructure/Mods/Models/LegacyContentThemeManifest.cs new file mode 100644 index 00000000..14e6f6b7 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/LegacyContentThemeManifest.cs @@ -0,0 +1,37 @@ +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Represents the per-modification palette using the exact property names accepted from the legacy remote backend. +/// +/// +/// The backend publishes this as a nested ColorsInformation mapping with PascalCase keys and no naming +/// convention applied, so these names are part of the external contract and must not be renamed. +/// +internal sealed class LegacyContentThemeManifest +{ + public string GenLauncherBorderColor { get; set; } = string.Empty; + + public string GenLauncherInactiveBorder { get; set; } = string.Empty; + + public string GenLauncherInactiveBorder2 { get; set; } = string.Empty; + + public string GenLauncherActiveColor { get; set; } = string.Empty; + + public string GenLauncherDarkFillColor { get; set; } = string.Empty; + + public string GenLauncherDarkBackGround { get; set; } = string.Empty; + + public string GenLauncherLightBackGround { get; set; } = string.Empty; + + public string GenLauncherDefaultTextColor { get; set; } = string.Empty; + + public string GenLauncherDownloadTextColor { get; set; } = string.Empty; + + public string GenLauncherListBoxSelectionColor1 { get; set; } = string.Empty; + + public string GenLauncherListBoxSelectionColor2 { get; set; } = string.Empty; + + public string GenLauncherButtonSelectionColor { get; set; } = string.Empty; + + public string GenLauncherBackgroundImageLink { get; set; } = string.Empty; +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/LegacyLauncherCatalogDocument.cs b/GenLauncherGO.Infrastructure/Mods/Models/LegacyLauncherCatalogDocument.cs new file mode 100644 index 00000000..922b00f8 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/LegacyLauncherCatalogDocument.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +// ReSharper disable InconsistentNaming + +/// +/// Represents the legacy top-level remote launcher catalog document. +/// +/// +/// These member names are owned by the remote backend and are intentionally preserved for exact YAML binding. +/// Infrastructure maps this transport shape to before exposing catalog data. +/// +internal sealed class LegacyLauncherCatalogDocument +{ + public List AdvData { get; set; } = []; + + public List globalAddonsData { get; set; } = []; + + public List modDatas { get; set; } = []; + + public List originalGameAddons { get; set; } = []; + + public List originalGamePatches { get; set; } = []; + + public string LauncherVersion { get; set; } = string.Empty; +} + +// ReSharper restore InconsistentNaming diff --git a/GenLauncherGO.Infrastructure/Mods/Models/RemoteAdvertisingReference.cs b/GenLauncherGO.Infrastructure/Mods/Models/RemoteAdvertisingReference.cs new file mode 100644 index 00000000..ea954693 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/RemoteAdvertisingReference.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Represents a normalized remote advertising manifest reference. +/// +internal sealed class RemoteAdvertisingReference +{ + public RemoteAdvertisingReference( + string name, + string manifestUrl, + IReadOnlyList imageUrls) + { + Name = name ?? string.Empty; + ManifestUrl = manifestUrl ?? string.Empty; + ImageUrls = imageUrls ?? Array.Empty(); + } + + public string Name { get; } + + public string ManifestUrl { get; } + + 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..28ca5c33 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/RemoteCatalogModificationReference.cs @@ -0,0 +1,30 @@ +using System; +using System.Collections.Generic; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Represents a normalized remote modification manifest reference. +/// +internal sealed class RemoteCatalogModificationReference +{ + 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(); + } + + public string Name { get; } + + public string ManifestUrl { get; } + + public IReadOnlyList PatchManifestUrls { get; } + + public IReadOnlyList AddonManifestUrls { get; } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/RemoteChildManifestLoadResult.cs b/GenLauncherGO.Infrastructure/Mods/Models/RemoteChildManifestLoadResult.cs new file mode 100644 index 00000000..51b0e63f --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/RemoteChildManifestLoadResult.cs @@ -0,0 +1,25 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Describes a child-manifest load that may contain partial remote results. +/// +internal sealed class RemoteChildManifestLoadResult +{ + public RemoteChildManifestLoadResult( + IReadOnlyList contentVersions, + int failedCount) + { + ContentVersions = contentVersions ?? Array.Empty(); + FailedCount = failedCount; + } + + public IReadOnlyList ContentVersions { get; } + + public int FailedCount { get; } + + public bool Succeeded => FailedCount == 0; +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/RemoteLauncherCatalog.cs b/GenLauncherGO.Infrastructure/Mods/Models/RemoteLauncherCatalog.cs new file mode 100644 index 00000000..be9caa31 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/RemoteLauncherCatalog.cs @@ -0,0 +1,36 @@ +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 +{ + public RemoteLauncherCatalog( + IReadOnlyList advertisingEntries, + IReadOnlyList modifications, + IReadOnlyList originalGameAddonManifestUrls, + IReadOnlyList originalGamePatchManifestUrls) + { + AdvertisingEntries = advertisingEntries ?? Array.Empty(); + Modifications = modifications ?? Array.Empty(); + OriginalGameAddonManifestUrls = originalGameAddonManifestUrls ?? Array.Empty(); + OriginalGamePatchManifestUrls = originalGamePatchManifestUrls ?? Array.Empty(); + } + + public static RemoteLauncherCatalog Empty { get; } = new( + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty()); + + public IReadOnlyList AdvertisingEntries { get; } + + public IReadOnlyList Modifications { get; } + + public IReadOnlyList OriginalGameAddonManifestUrls { get; } + + public IReadOnlyList OriginalGamePatchManifestUrls { get; } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Models/RemoteModificationManifest.cs b/GenLauncherGO.Infrastructure/Mods/Models/RemoteModificationManifest.cs new file mode 100644 index 00000000..cf7da8f3 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Models/RemoteModificationManifest.cs @@ -0,0 +1,27 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Models; + +/// +/// Represents a normalized remote modification manifest with its child manifest references. +/// +internal sealed class RemoteModificationManifest +{ + public RemoteModificationManifest( + LauncherContentVersion content, + IReadOnlyList patchManifestUrls, + IReadOnlyList addonManifestUrls) + { + Content = content ?? throw new ArgumentNullException(nameof(content)); + PatchManifestUrls = patchManifestUrls ?? Array.Empty(); + AddonManifestUrls = addonManifestUrls ?? Array.Empty(); + } + + public LauncherContentVersion Content { get; } + + public IReadOnlyList PatchManifestUrls { get; } + + 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..fd753e2f --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemLocalLauncherContentService.cs @@ -0,0 +1,352 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Mods.Services; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Mods.Contracts; +using GenLauncherGO.Infrastructure.Mods.Support; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Performs local file-system operations for launcher-managed mods, patches, add-ons, and cached images. +/// +internal sealed class FileSystemLocalLauncherContentService : ILocalLauncherContentService +{ + private readonly ILogger _logger; + + public FileSystemLocalLauncherContentService(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public IReadOnlyList FindInstalledVersions(LauncherPaths paths) + { + ArgumentNullException.ThrowIfNull(paths); + + var versions = new List(); + var modsDirectory = new DirectoryInfo(paths.ModsDirectory); + if (!modsDirectory.Exists) + { + return versions; + } + + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + paths.ModsDirectory, + "Launcher content paths"); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + paths.ModsDirectory, + "Launcher content paths"); + + foreach (DirectoryInfo contentDirectory in modsDirectory.GetDirectories()) + { + AddInstalledVersions(contentDirectory, versions); + } + + return versions; + } + + public void DeleteEmptyPackageBackupDirectories(LauncherPaths paths) + { + ArgumentNullException.ThrowIfNull(paths); + + if (OwnedDirectoryTree.DeleteEmptyDirectories(paths.PackageBackupsDirectory)) + { + _logger.LogDebug("Deleted the empty launcher package recovery directory."); + } + } + + public void DeleteVersion( + LauncherPaths paths, + LauncherContentKey contentKey) + { + DeleteResolvedContent( + paths, + contentKey, + LauncherContentPathResolver.ResolveVersionPath, + "Deleted launcher content version"); + } + + public void DeleteContent( + LauncherPaths paths, + LauncherContentKey contentKey) + { + DeleteResolvedContent( + paths, + contentKey, + LauncherContentPathResolver.ResolveContentPath, + "Deleted launcher content"); + } + + /// + /// Deletes resolved launcher content together with its resumable staging and durable recovery state. + /// + private void DeleteResolvedContent( + LauncherPaths paths, + LauncherContentKey contentKey, + Func resolvePath, + string deletionDescription) + { + ArgumentNullException.ThrowIfNull(paths); + + OwnedContentPath? resolvedPath; + try + { + resolvedPath = resolvePath(paths, contentKey); + } + catch (ArgumentException exception) + { + throw new InvalidOperationException( + "Refusing to delete a launcher content path outside the mods root.", + exception); + } + + if (resolvedPath is null) + { + return; + } + + bool deletedContent = DeleteDirectoryIfExists( + resolvedPath, + deletionDescription, + contentKey.Name, + contentKey.Version); + + DeletePackageStagingDirectory(paths, resolvedPath, contentKey); + DeletePackageBackupDirectory(paths, resolvedPath, contentKey); + + if (!deletedContent) + { + return; + } + + OwnedContentPath? cleanupRoot = LauncherContentPathResolver.ResolveCleanupRootPath(paths, contentKey); + if (cleanupRoot is not null) + { + OwnedDirectoryTree.DeleteEmptyDirectories(cleanupRoot); + } + } + + public void DeleteImagesIfUnused( + LauncherPaths paths, + LauncherContentKey contentKey, + LauncherData launcherData) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(launcherData); + + if (string.IsNullOrWhiteSpace(contentKey.Name) || + ContentCardExists(launcherData, contentKey.Name)) + { + return; + } + + string imageFolderPath = ModificationImageCachePath.GetDirectoryPath( + paths, + contentKey.ContentType, + contentKey.Name); + if (!Directory.Exists(imageFolderPath)) + { + return; + } + + var ownedImagePath = new OwnedContentPath(paths.ImagesDirectory, imageFolderPath); + bool removedLinkedCache = FileSystemPathSafety.IsReparsePoint(imageFolderPath); + if (!OwnedDirectoryTree.DeleteIfExists(ownedImagePath)) + { + return; + } + + if (removedLinkedCache) + { + _logger.LogWarning( + "Removed linked modification image cache folder {ImageFolderName} without traversing its target.", + Path.GetFileName(imageFolderPath)); + return; + } + + _logger.LogDebug( + "Deleted unused modification image cache folder {ImageFolderName}.", + Path.GetFileName(imageFolderPath)); + } + + private static void AddInstalledVersions( + DirectoryInfo contentDirectory, + List versions) + { + foreach (DirectoryInfo subDirectory in contentDirectory.GetDirectories()) + { + ModificationType? childContentType = null; + if (string.Equals( + subDirectory.Name, + LauncherFileSystemLayout.AddonsFolderName, + StringComparison.OrdinalIgnoreCase)) + { + childContentType = ModificationType.Addon; + } + else if (string.Equals( + subDirectory.Name, + LauncherFileSystemLayout.PatchesFolderName, + StringComparison.OrdinalIgnoreCase)) + { + childContentType = ModificationType.Patch; + } + + if (childContentType.HasValue) + { + foreach (DirectoryInfo childDirectory in subDirectory.GetDirectories()) + { + AddInstalledChildVersions( + childDirectory, + contentDirectory.Name, + childContentType.Value, + versions); + } + + continue; + } + + if (IsInstallVersionDirectory(subDirectory)) + { + versions.Add(new LauncherContentVersion(new LauncherContentInstallation + { + Installed = true + }) + { + ModificationType = ModificationType.Mod, + Name = contentDirectory.Name, + Version = subDirectory.Name + }); + } + } + } + + private static void AddInstalledChildVersions( + DirectoryInfo contentDirectory, + string parentContentName, + ModificationType contentType, + List versions) + { + foreach (DirectoryInfo versionDirectory in contentDirectory.GetDirectories()) + { + if (!IsInstallVersionDirectory(versionDirectory)) + { + continue; + } + + versions.Add(new LauncherContentVersion(new LauncherContentInstallation + { + Installed = true + }) + { + ModificationType = contentType, + Name = contentDirectory.Name, + Version = versionDirectory.Name, + ParentContentName = parentContentName + }); + } + } + + private static bool IsInstallVersionDirectory(DirectoryInfo directory) + { + return directory.EnumerateFiles("*", SearchOption.AllDirectories).Any(); + } + + /// + /// Deletes the temporary package staging directory for a content version when it exists. + /// + private void DeletePackageStagingDirectory( + LauncherPaths paths, + OwnedContentPath versionPath, + LauncherContentKey contentKey) + { + OwnedContentPath packageStagingPath = paths.GetPackageTemporaryPath(versionPath); + + DeleteDirectoryIfExists( + packageStagingPath, + "Deleted temporary launcher package staging folder for", + contentKey.Name, + contentKey.Version); + DeleteEmptyPackageStagingParents(packageStagingPath); + } + + /// + /// Deletes the durable recovery backup for content the user deliberately removed. + /// + private void DeletePackageBackupDirectory( + LauncherPaths paths, + OwnedContentPath installedPath, + LauncherContentKey contentKey) + { + OwnedContentPath packageBackupPath = paths.GetPackageBackupPath(installedPath); + + DeleteDirectoryIfExists( + packageBackupPath, + "Deleted obsolete launcher package recovery backup for", + contentKey.Name, + contentKey.Version); + DeleteEmptyPackageBackupParents(packageBackupPath); + } + + /// + /// Deletes empty package staging parent directories without crossing outside the package staging root. + /// + private void DeleteEmptyPackageStagingParents(OwnedContentPath packageStagingPath) + { + foreach (string deletedDirectory in OwnedDirectoryTree.DeleteEmptyParents( + packageStagingPath.OwnerRoot, + packageStagingPath.FullPath)) + { + _logger.LogDebug( + "Deleted empty temporary launcher package staging folder {StagingFolderName}.", + Path.GetFileName(deletedDirectory)); + } + } + + /// + /// Deletes empty recovery-backup parent directories without crossing outside the package backup root. + /// + private void DeleteEmptyPackageBackupParents(OwnedContentPath packageBackupPath) + { + foreach (string deletedDirectory in OwnedDirectoryTree.DeleteEmptyParentsIncludingRoot( + packageBackupPath.OwnerRoot, + packageBackupPath.FullPath)) + { + _logger.LogDebug( + "Deleted empty launcher package recovery folder {BackupFolderName}.", + Path.GetFileName(deletedDirectory)); + } + } + + private bool DeleteDirectoryIfExists( + OwnedContentPath ownedPath, + string deletionDescription, + string contentName, + string contentVersion) + { + if (!OwnedDirectoryTree.DeleteIfExists(ownedPath)) + { + return false; + } + + // The template stays constant so structured sinks can group these; the part + // that varies per caller is carried as a property rather than in the template. + _logger.LogInformation( + "{DeletionDescription} {ContentName} {ContentVersion}.", + deletionDescription, + contentName, + contentVersion); + return true; + } + + private static bool ContentCardExists(LauncherData launcherData, string contentName) + { + return launcherData.AllContent + .Any(entry => entry.ContentKey.HasName(contentName)); + } + +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/FileSystemManualModificationImporter.cs b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemManualModificationImporter.cs new file mode 100644 index 00000000..93e330ac --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemManualModificationImporter.cs @@ -0,0 +1,154 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Archives.Contracts; +using GenLauncherGO.Infrastructure.Common; +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. +/// +internal sealed class FileSystemManualModificationImporter : IManualModificationImporter +{ + private readonly IArchiveExtractor _archiveExtractor; + + private readonly ILogger _logger; + + public FileSystemManualModificationImporter( + IArchiveExtractor archiveExtractor, + ILogger logger) + { + _archiveExtractor = archiveExtractor ?? throw new ArgumentNullException(nameof(archiveExtractor)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public void Import( + IReadOnlyList sourceFilePaths, + OwnedContentPath destinationPath, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(sourceFilePaths); + ArgumentNullException.ThrowIfNull(destinationPath); + + if (sourceFilePaths.Count == 0) + { + throw new ArgumentException("At least one source file is required.", nameof(sourceFilePaths)); + } + + string destinationDirectory = destinationPath.FullPath; + try + { + foreach (string sourceFilePath in sourceFilePaths) + { + cancellationToken.ThrowIfCancellationRequested(); + destinationDirectory = PrepareSafeDestination(destinationPath); + ImportFile( + sourceFilePath, + destinationPath, + destinationDirectory, + cancellationToken); + } + + _logger.LogInformation( + "Imported {FileCount} manual content file(s) to {DestinationDirectory}.", + 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; + } + } + + private void ImportFile( + string sourceFilePath, + OwnedContentPath destinationPath, + 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 = ResolveSafeDestinationFilePath( + destinationDirectory, + Path.Combine(destinationDirectory, sourceFileName)); + if (!File.Exists(destinationFilePath)) + { + File.Copy(sourceFilePath, destinationFilePath); + } + + if (LauncherContentFileTypes.IsArchive(sourceFileName)) + { + destinationDirectory = PrepareSafeDestination(destinationPath); + destinationFilePath = ResolveSafeDestinationFilePath( + destinationDirectory, + destinationFilePath); + _archiveExtractor.ExtractToDirectory( + destinationFilePath, + destinationDirectory, + cancellationToken: cancellationToken); + destinationDirectory = PrepareSafeDestination(destinationPath); + ResolveSafeDestinationFilePath(destinationDirectory, destinationFilePath); + File.Delete(destinationFilePath); + return; + } + + string installedFilePath = BigFileVariantPath.GetInstalledPath(destinationFilePath); + if (!LexicalPath.AreEquivalent(installedFilePath, destinationFilePath)) + { + string gibFilePath = ResolveSafeDestinationFilePath( + destinationDirectory, + installedFilePath); + File.Move(destinationFilePath, gibFilePath); + } + } + + /// + /// Creates the owned destination when needed and rejects any linked path before mutation or extraction. + /// + private static string PrepareSafeDestination(OwnedContentPath destinationPath) + { + string destinationDirectory = FileSystemPathSafety.ResolveOwnedSubpath( + destinationPath.OwnerRoot, + destinationPath.FullPath, + "Manual import destinations", + "their launcher-owned root"); + destinationDirectory = OwnedDirectoryTree.EnsureExists( + destinationPath.OwnerRoot, + destinationDirectory); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + destinationDirectory, + "Manual import destinations"); + return destinationDirectory; + } + + /// + /// Resolves one destination file and rejects paths or existing entries outside the safe import directory. + /// + private static string ResolveSafeDestinationFilePath( + string destinationDirectory, + string candidatePath) + { + return FileSystemPathSafety.ResolveOwnedSubpath( + destinationDirectory, + candidatePath, + "Manual import files", + "their destination directory"); + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/FileSystemModificationImageFileService.cs b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemModificationImageFileService.cs new file mode 100644 index 00000000..7b0a51b0 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemModificationImageFileService.cs @@ -0,0 +1,243 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Mods.Support; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Manages cached modification image files on disk. +/// +internal sealed class FileSystemModificationImageFileService : IModificationImageFileService +{ + private readonly ILogger _logger; + private readonly LauncherRuntimePathContext _runtimePathContext; + + public FileSystemModificationImageFileService( + LauncherRuntimePathContext runtimePathContext, + ILogger logger) + { + _runtimePathContext = runtimePathContext ?? throw new ArgumentNullException(nameof(runtimePathContext)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public string? FindExistingImageFilePath( + ModificationType modificationType, + string modificationName, + string imageBaseName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(modificationName); + ArgumentException.ThrowIfNullOrWhiteSpace(imageBaseName); + + LauncherPaths paths = _runtimePathContext.ActivePaths; + string? imageDirectory = ResolveExistingImageDirectory(paths, modificationType, modificationName); + if (imageDirectory is null) + { + return null; + } + + string? imageFilePath = Directory.EnumerateFiles( + imageDirectory, + GetImageSearchPattern(paths, modificationType, modificationName, imageBaseName)) + .FirstOrDefault(); + return imageFilePath is null + ? null + : ModificationImageCachePath.ResolvePath(paths, imageFilePath); + } + + public int CountImageFiles(ModificationType modificationType, string modificationName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(modificationName); + + LauncherPaths paths = _runtimePathContext.ActivePaths; + string? imageDirectory = ResolveExistingImageDirectory(paths, modificationType, modificationName); + if (imageDirectory is null) + { + return 0; + } + + return Directory.EnumerateFiles(imageDirectory).Count(); + } + + public bool ImageExists(string? imageFilePath) + { + if (string.IsNullOrWhiteSpace(imageFilePath)) + { + return false; + } + + try + { + return File.Exists(ModificationImageCachePath.ResolvePath( + _runtimePathContext.ActivePaths, + imageFilePath)); + } + catch (Exception exception) when (ModificationCacheFailure.IsRecoverable(exception)) + { + return false; + } + } + + public bool TryDeleteImage( + ModificationType modificationType, + string modificationName, + string imageBaseName) + { + try + { + LauncherPaths paths = _runtimePathContext.ActivePaths; + string? imageDirectory = ResolveExistingImageDirectory(paths, modificationType, modificationName); + if (imageDirectory is null) + { + return true; + } + + string imageSearchPattern = GetImageSearchPattern( + paths, + modificationType, + modificationName, + imageBaseName); + foreach (string imageFilePath in Directory.EnumerateFiles(imageDirectory, imageSearchPattern).ToList()) + { + File.Delete(ModificationImageCachePath.ResolvePath(paths, imageFilePath)); + } + + return true; + } + catch (Exception exception) when (ModificationCacheFailure.IsRecoverable(exception)) + { + _logger.LogWarning( + exception, + "Could not remove cached modification image {ImageBaseName} for {ModificationName}.", + imageBaseName, + modificationName); + return false; + } + } + + public Task ReplaceImageAsync( + ModificationImageReplacementRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + LauncherPaths paths = _runtimePathContext.ActivePaths; + return Task.Run(() => ReplaceImage(paths, request, cancellationToken), cancellationToken); + } + + /// + /// Replaces the cached image file and removes stale sibling extensions. + /// + private string ReplaceImage( + LauncherPaths paths, + 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 sourcePath = LexicalPath.NormalizeFullPath(request.SourceImagePath); + + try + { + string destinationDirectory = OwnedDirectoryTree.EnsureExists( + paths.ImagesDirectory, + ModificationImageCachePath.ResolveDirectory( + paths, + ModificationType.Mod, + request.ModificationName)); + string destinationPath = ModificationImageCachePath.ResolveImagePath( + paths, + ModificationType.Mod, + request.ModificationName, + request.ImageBaseName + extension); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + destinationDirectory, + "Cached modification image directories"); + + if (LexicalPath.AreEquivalent(sourcePath, destinationPath)) + { + return destinationPath; + } + + string imageSearchPattern = Path.GetFileNameWithoutExtension(destinationPath) + ".*"; + foreach (string existingImagePath in Directory.EnumerateFiles(destinationDirectory, imageSearchPattern)) + { + cancellationToken.ThrowIfCancellationRequested(); + File.Delete(ModificationImageCachePath.ResolvePath(paths, existingImagePath)); + } + + cancellationToken.ThrowIfCancellationRequested(); + File.Copy(sourcePath, destinationPath); + return destinationPath; + } + catch (Exception exception) when (ModificationCacheFailure.IsRecoverable(exception)) + { + _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); + } + } + + /// + /// Resolves an existing image directory and rejects linked entries before callers enumerate it. + /// + private static string? ResolveExistingImageDirectory( + LauncherPaths paths, + ModificationType modificationType, + string modificationName) + { + string imageDirectory = ModificationImageCachePath.ResolveDirectory( + paths, + modificationType, + modificationName); + if (!Directory.Exists(imageDirectory)) + { + return null; + } + + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + imageDirectory, + "Cached modification image directories"); + return imageDirectory; + } + + private static string GetImageSearchPattern( + LauncherPaths paths, + ModificationType modificationType, + string modificationName, + string imageBaseName) + { + string validatedImagePath = ModificationImageCachePath.ResolveImagePath( + paths, + modificationType, + modificationName, + imageBaseName + ".cache"); + return Path.GetFileNameWithoutExtension(validatedImagePath) + ".*"; + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/FileSystemModificationThemeCache.cs b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemModificationThemeCache.cs new file mode 100644 index 00000000..7e62ec08 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/FileSystemModificationThemeCache.cs @@ -0,0 +1,133 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Mods.Support; +using GenLauncherGO.Infrastructure.Persistence.Services; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Caches published modification palettes as YAML beside the artwork they belong to. +/// +/// +/// Reusing the modification image cache directory is deliberate: that folder already has an ownership boundary, +/// reparse-point defences, and removal when the content card goes away, so the palette inherits all of it instead +/// of needing a second cache location with its own lifetime rules. +/// +internal sealed class FileSystemModificationThemeCache : IModificationThemeCache +{ + private readonly IAtomicFileWriter _atomicFileWriter; + + private readonly ILogger> _documentLogger; + + private readonly ILogger _logger; + private readonly LauncherRuntimePathContext _runtimePathContext; + + public FileSystemModificationThemeCache( + LauncherRuntimePathContext runtimePathContext, + IAtomicFileWriter atomicFileWriter, + ILogger> documentLogger, + ILogger logger) + { + _runtimePathContext = runtimePathContext ?? throw new ArgumentNullException(nameof(runtimePathContext)); + _atomicFileWriter = atomicFileWriter ?? throw new ArgumentNullException(nameof(atomicFileWriter)); + _documentLogger = documentLogger ?? throw new ArgumentNullException(nameof(documentLogger)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public void Save(LauncherContentKey contentKey, LauncherContentTheme theme) + { + ArgumentNullException.ThrowIfNull(theme); + + if (!TryResolveDocumentPath(contentKey, out string documentPath)) + { + return; + } + + try + { + OwnedDirectoryTree.EnsureExists( + _runtimePathContext.ActivePaths.ImagesDirectory, + Path.GetDirectoryName(documentPath)!); + CreateDocumentStore(documentPath).Save(theme); + } + catch (Exception exception) when (ModificationCacheFailure.IsRecoverable(exception)) + { + // A palette that cannot be cached only costs the offline re-skin, so never fail the catalog for it. + _logger.LogWarning( + exception, + "Could not cache the published palette for {ModificationName} {Version}.", + contentKey.Name, + contentKey.Version); + } + } + + public LauncherContentTheme? Load(LauncherContentKey contentKey) + { + if (!TryResolveDocumentPath(contentKey, out string documentPath)) + { + return null; + } + + try + { + IYamlDocumentStore store = CreateDocumentStore(documentPath); + if (!store.DocumentExists) + { + return null; + } + + LauncherContentTheme empty = new(); + LauncherContentTheme cached = store.Load(empty); + return ReferenceEquals(cached, empty) ? null : cached; + } + catch (Exception exception) when (ModificationCacheFailure.IsRecoverable(exception)) + { + _logger.LogWarning( + exception, + "Could not read the cached palette for {ModificationName} {Version}.", + contentKey.Name, + contentKey.Version); + return null; + } + } + + private bool TryResolveDocumentPath(LauncherContentKey contentKey, out string documentPath) + { + documentPath = string.Empty; + if (string.IsNullOrWhiteSpace(contentKey.Name) || string.IsNullOrWhiteSpace(contentKey.Version)) + { + return false; + } + + try + { + LauncherPaths paths = _runtimePathContext.ActivePaths; + documentPath = ModificationImageCachePath.ResolveImagePath( + paths, + contentKey.ContentType, + contentKey.Name, + LauncherContentTheme.ResolveCacheBaseName(contentKey.Version) + ".yaml"); + return true; + } + catch (Exception exception) when (ModificationCacheFailure.IsRecoverable(exception)) + { + _logger.LogWarning( + exception, + "Could not resolve the palette cache path for {ModificationName} {Version}.", + contentKey.Name, + contentKey.Version); + return false; + } + } + + private IYamlDocumentStore CreateDocumentStore(string documentPath) + { + return new YamlDocumentStore(documentPath, _atomicFileWriter, _documentLogger); + } + +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/LauncherCatalogImageCache.cs b/GenLauncherGO.Infrastructure/Mods/Services/LauncherCatalogImageCache.cs new file mode 100644 index 00000000..57a6e553 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/LauncherCatalogImageCache.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +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.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Support; +using GenLauncherGO.Infrastructure.Remote.Contracts; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Caches the assets a remote launcher catalog publishes: tile art, shell artwork, and palettes. +/// +/// +/// Everything cached here shares one lifetime, because it all lands in the modification's own cache folder and is +/// removed with the content card. +/// +internal sealed class LauncherCatalogImageCache +{ + private readonly IRemoteAssetDownloader _assetDownloader; + + private readonly ILogger _logger; + + private readonly IModificationThemeCache _themeCache; + + public LauncherCatalogImageCache( + IRemoteAssetDownloader assetDownloader, + IModificationThemeCache themeCache, + ILogger logger) + { + _assetDownloader = assetDownloader ?? throw new ArgumentNullException(nameof(assetDownloader)); + _themeCache = themeCache ?? throw new ArgumentNullException(nameof(themeCache)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task CacheModificationImagesAsync( + LauncherContentVersion modification, + LauncherPaths paths, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(modification); + ArgumentNullException.ThrowIfNull(paths); + + bool themeSaved = false; + foreach ((string link, string baseName, bool requiresTheme) in + ModificationImageCachePath.GetRemoteAssetSources(modification)) + { + if (requiresTheme && !themeSaved) + { + SaveTheme(modification); + themeSaved = true; + } + + await DownloadImageIfMissingAsync( + paths, + modification.ModificationType, + modification.Name, + baseName, + link, + cancellationToken).ConfigureAwait(false); + } + + if (modification.Theme is not null && !themeSaved) + { + SaveTheme(modification); + } + } + + private void SaveTheme(LauncherContentVersion modification) + { + // Cached ahead of selection so a themed modification can re-skin the shell the moment it is picked, and + // so a later offline run still has both the palette and the artwork it refers to. + _themeCache.Save(modification.ContentKey, modification.Theme!); + } + + 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, + ModificationType.Advertising, + advertising.Name, + currentImageIndex.ToString(CultureInfo.CurrentCulture), + imageLink, + cancellationToken)); + imageIndex++; + } + + await Task.WhenAll(imageDownloads).ConfigureAwait(false); + } + + /// + /// Removes stale advertising image files when the remote image count changes. + /// + private void RemoveStaleAdvertisingImages( + RemoteAdvertisingReference advertising, + LauncherPaths paths) + { + try + { + string imageFolderPath = ModificationImageCachePath.ResolveDirectory( + paths, + ModificationType.Advertising, + advertising.Name); + if (!Directory.Exists(imageFolderPath)) + { + return; + } + + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + imageFolderPath, + "Cached catalog image directories"); + var dirInfo = new DirectoryInfo(imageFolderPath); + FileInfo[] images = dirInfo.GetFiles(); + if (images.Length == advertising.ImageUrls.Count) + { + return; + } + + foreach (FileInfo image in images) + { + try + { + File.Delete(ModificationImageCachePath.ResolvePath(paths, image.FullName)); + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to delete stale advertising image {ImageFileName}.", + image.Name); + } + } + } + catch (Exception exception) when (ModificationCacheFailure.IsRecoverable(exception)) + { + _logger.LogWarning( + exception, + "Skipped stale advertising image cleanup for {ModificationName} because its cache path was unavailable.", + advertising.Name); + } + } + + private async Task DownloadImageIfMissingAsync( + LauncherPaths paths, + ModificationType modificationType, + string modificationName, + string fileName, + string link, + CancellationToken cancellationToken) + { + try + { + var sourceUri = new Uri(link, UriKind.Absolute); + string imageDirectory = OwnedDirectoryTree.EnsureExists( + paths.ImagesDirectory, + ModificationImageCachePath.ResolveDirectory(paths, modificationType, modificationName)); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + imageDirectory, + "Cached catalog image directories"); + string destinationFilePath = ModificationImageCachePath.ResolveRemoteImagePath( + paths, + modificationType, + modificationName, + fileName, + sourceUri); + await _assetDownloader.DownloadIfMissingAsync( + sourceUri, + destinationFilePath, + cancellationToken).ConfigureAwait(false); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + imageDirectory, + "Cached catalog image directories"); + _ = ModificationImageCachePath.ResolvePath(paths, destinationFilePath); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to download cached image {ImageName} for {ModificationName}.", + fileName, + modificationName); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentCatalogService.cs b/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentCatalogService.cs new file mode 100644 index 00000000..24b83415 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentCatalogService.cs @@ -0,0 +1,535 @@ +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.Exceptions; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Contracts; +using GenLauncherGO.Infrastructure.Mods.Models; +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 : ILauncherContentCatalog +{ + private const int MaxConcurrentImageCacheUpdates = 8; + + /// + /// Serializes asynchronous catalog mutation so in-flight work cannot cross a game-session boundary. + /// + private readonly SemaphoreSlim _catalogMutationGate = new(1, 1); + + private readonly ILauncherContentStateStore _contentStateStore; + + private readonly LauncherCatalogImageCache _imageCache; + + private readonly LauncherLocalContentReconciler _localContentReconciler; + + private readonly ILogger _logger; + + private readonly RemoteLauncherCatalogClient _remoteCatalogClient; + + private readonly IModificationThemeCache _themeCache; + + private CatalogSessionState _state = new(); + + public LauncherContentCatalogService( + ILauncherContentStateStore contentStateStore, + RemoteLauncherCatalogClient remoteCatalogClient, + LauncherCatalogImageCache imageCache, + LauncherLocalContentReconciler localContentReconciler, + IModificationThemeCache themeCache, + ILogger logger) + { + _contentStateStore = contentStateStore ?? throw new ArgumentNullException(nameof(contentStateStore)); + _remoteCatalogClient = remoteCatalogClient ?? throw new ArgumentNullException(nameof(remoteCatalogClient)); + _imageCache = imageCache ?? throw new ArgumentNullException(nameof(imageCache)); + _localContentReconciler = + localContentReconciler ?? throw new ArgumentNullException(nameof(localContentReconciler)); + _themeCache = themeCache ?? throw new ArgumentNullException(nameof(themeCache)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + private LauncherPaths Paths => + _state.Paths ?? throw new InvalidOperationException("Launcher content catalog has not been initialized."); + + public LauncherData Data => _state.Data; + + public LauncherContentVersion? Advertising => _state.Advertising; + + public IReadOnlyList? RepositoryModificationNames => _state.RepositoryModificationNames; + + public async Task InitDataAsync( + LauncherContentCatalogInitializationRequest request, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(request.Paths); + + await RunWithCatalogMutationGateAsync( + async () => + { + CatalogSessionState previousState = _state; + try + { + _state = new CatalogSessionState(request.Paths); + await InitializeDataCoreAsync(request, cancellationToken).ConfigureAwait(false); + } + catch + { + _state = previousState; + throw; + } + }, + cancellationToken).ConfigureAwait(false); + } + + public async Task ReadOriginalGameAddonsAndPatchesAsync(CancellationToken cancellationToken) + { + await RunWithCatalogMutationGateAsync( + () => ReadOriginalGameAddonsAndPatchesCoreAsync(cancellationToken), + cancellationToken).ConfigureAwait(false); + } + + public async Task GetRepositoryModificationMetadataAsync( + string name, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + return await RunWithCatalogMutationGateAsync( + async () => + { + RemoteModificationManifest manifest = await GetRepositoryModificationManifestAsync( + name, + cancellationToken).ConfigureAwait(false); + return manifest.Content; + }, + cancellationToken).ConfigureAwait(false); + } + + public async Task AddRepositoryModificationAsync( + string name, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + return await RunWithCatalogMutationGateAsync( + async () => + { + RemoteModificationManifest manifest = await GetRepositoryModificationManifestAsync( + name, + cancellationToken).ConfigureAwait(false); + AddRemoteModificationManifest(manifest); + + await _imageCache.CacheModificationImagesAsync(manifest.Content, Paths, cancellationToken) + .ConfigureAwait(false); + AddDownloadedModificationData(manifest.Content); + return manifest.Content; + }, + cancellationToken).ConfigureAwait(false); + } + + public void UninstallVersion(LauncherContentKey contentKey) + { + _localContentReconciler.DeleteVersion(contentKey, Paths); + UpdateLocalModificationsData(); + } + + public void DiscardVersion(LauncherContentKey contentKey) + { + _localContentReconciler.DiscardVersion(_state.Data, contentKey, Paths); + UpdateLocalModificationsData(); + } + + public void DiscardContent(LauncherContentKey contentKey) + { + _localContentReconciler.DiscardContent(_state.Data, contentKey, Paths); + UpdateLocalModificationsData(); + } + + public async Task ReadPatchesAndAddonsForModAsync( + LauncherContentKey modificationKey, + CancellationToken cancellationToken) + { + await RunWithCatalogMutationGateAsync( + () => ReadPatchesAndAddonsForModCoreAsync(modificationKey, cancellationToken), + cancellationToken).ConfigureAwait(false); + } + + public void UpdateLocalModificationsData() + { + _localContentReconciler.Reconcile(_state.Data, _state.DownloadedRepositoryContent, Paths); + } + + public void SaveLauncherData() + { + try + { + _contentStateStore.Save(Paths, LauncherContentStateMapper.ToLauncherContentState(_state.Data)); + } + catch (Exception exception) + { + _logger.LogError( + exception, + "Failed to persist launcher content state. The current in-memory catalog remains available for retry."); + throw new LauncherContentPersistenceException(exception); + } + } + + private async Task RunWithCatalogMutationGateAsync( + Func operation, + CancellationToken cancellationToken) + { + await _catalogMutationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await operation().ConfigureAwait(false); + } + finally + { + _catalogMutationGate.Release(); + } + } + + private async Task RunWithCatalogMutationGateAsync( + Func> operation, + CancellationToken cancellationToken) + { + await _catalogMutationGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await operation().ConfigureAwait(false); + } + finally + { + _catalogMutationGate.Release(); + } + } + + /// + /// Reads and caches one manifest while the caller owns the catalog mutation gate. + /// + private async Task GetRepositoryModificationManifestAsync( + string name, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + var contentKey = LauncherContentKey.ForModificationName(name); + if (_state.RepositoryMetadataCache.TryGetValue( + contentKey, + out RemoteModificationManifest? cachedManifest)) + { + return cachedManifest; + } + + RemoteModificationManifest manifest = await _remoteCatalogClient.DownloadModDataByNameAsync( + _state.RepositoryData ?? RemoteLauncherCatalog.Empty, + name, + cancellationToken).ConfigureAwait(false); + _state.RepositoryMetadataCache[contentKey] = manifest; + return manifest; + } + + private void ReadLocalModsData() + { + _state.Data = LauncherContentStateMapper.ToLauncherData(_contentStateStore.Load(Paths), _themeCache); + } + + /// + /// Loads one game-specific catalog after the previous state has been isolated for rollback. + /// + private async Task InitializeDataCoreAsync( + LauncherContentCatalogInitializationRequest request, + CancellationToken cancellationToken) + { + if (request.RemoteManifestUri is not null) + { + await ReadMainManifestAsync(request.RemoteManifestUri, cancellationToken).ConfigureAwait(false); + } + + ReadLocalModsData(); + UpdateLocalModificationsData(); + + if (_state.RepositoryData is null) + { + LogCatalogInitialized(); + return; + } + + RemoteLauncherCatalog repositoryData = _state.RepositoryData; + var installedMods = _state.Data.Modifications.Select(mod => mod.Name).ToList(); + _state.RepositoryModificationNames = _remoteCatalogClient.GetModificationNames(repositoryData); + + IReadOnlyList installedManifests = await _remoteCatalogClient + .DownloadInstalledModDataAsync( + repositoryData, + installedMods, + cancellationToken) + .ConfigureAwait(false); + _state.ModificationsAndAddons = ToManifestDictionary(installedManifests); + var reposMods = installedManifests.Select(manifest => manifest.Content).ToList(); + + await CacheInstalledModificationImagesAsync(reposMods, cancellationToken).ConfigureAwait(false); + + foreach (LauncherContentVersion reposMod in reposMods) + { + AddDownloadedModificationData(reposMod); + } + + LauncherContent? selectedMod = _state.Data.GetSelectedMod(); + if (selectedMod != null) + { + await ReadPatchesAndAddonsForModCoreAsync(selectedMod.ContentKey, cancellationToken) + .ConfigureAwait(false); + } + + LogCatalogInitialized(); + } + + /// + /// Loads original-game children while the caller owns the catalog mutation gate. + /// + private async Task ReadOriginalGameAddonsAndPatchesCoreAsync(CancellationToken cancellationToken) + { + if (_state.RepositoryData is null) + { + return; + } + + RemoteLauncherCatalog repositoryData = _state.RepositoryData; + LauncherContentKey originalGameKey = LauncherContentKey.OriginalGame; + if (_state.DownloadedModificationInfo.Contains(originalGameKey)) + { + return; + } + + await LoadChildContentAsync( + originalGameKey, + repositoryData.OriginalGamePatchManifestUrls, + repositoryData.OriginalGameAddonManifestUrls, + originalGameKey.Name, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Loads one modification's children while the caller owns the catalog mutation gate. + /// + private async Task ReadPatchesAndAddonsForModCoreAsync( + LauncherContentKey modificationKey, + CancellationToken cancellationToken) + { + if (_state.RepositoryData is null) + { + return; + } + + var keyModification = LauncherContentKey.ForModificationName(modificationKey.Name); + if (_state.DownloadedModificationInfo.Contains(keyModification)) + { + return; + } + + if (!_state.ModificationsAndAddons.TryGetValue( + keyModification, + out RemoteModificationManifest? modData)) + { + return; + } + + await LoadChildContentAsync( + keyModification, + modData.PatchManifestUrls, + modData.AddonManifestUrls, + null, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Loads a content card's patch and add-on manifests concurrently and records a complete load only when both + /// groups succeeded. Successful partial results remain available while a later call retries failed manifests. + /// + private async Task LoadChildContentAsync( + LauncherContentKey contentKey, + IEnumerable patchManifestUrls, + IEnumerable addonManifestUrls, + string? parentContentName, + CancellationToken cancellationToken) + { + Task patchesTask = _remoteCatalogClient.ReadChildManifestsAsync( + patchManifestUrls, + parentContentName, + cancellationToken); + Task addonsTask = _remoteCatalogClient.ReadChildManifestsAsync( + addonManifestUrls, + parentContentName, + cancellationToken); + RemoteChildManifestLoadResult[] childManifestLoads = + await Task.WhenAll(patchesTask, addonsTask).ConfigureAwait(false); + RemoteChildManifestLoadResult patchLoad = childManifestLoads[0]; + RemoteChildManifestLoadResult addonLoad = childManifestLoads[1]; + + foreach (LauncherContentVersion patch in patchLoad.ContentVersions) + { + AddDownloadedModificationData(patch); + } + + foreach (LauncherContentVersion addon in addonLoad.ContentVersions) + { + AddDownloadedModificationData(addon); + } + + if (patchLoad.Succeeded && addonLoad.Succeeded) + { + _state.DownloadedModificationInfo.Add(contentKey); + } + } + + /// + /// Reads the top-level remote manifest and related advertising metadata. + /// + private async Task ReadMainManifestAsync(Uri manifestUri, CancellationToken cancellationToken) + { + RemoteLauncherCatalog repositoryData = await _remoteCatalogClient.ReadCatalogAsync( + manifestUri, + cancellationToken).ConfigureAwait(false); + _state.RepositoryData = repositoryData; + + if (repositoryData.AdvertisingEntries.Count > 0) + { + await DownloadAdvertisingDataAsync( + repositoryData.AdvertisingEntries[0], + cancellationToken).ConfigureAwait(false); + } + } + + private void AddDownloadedModificationData(LauncherContentVersion version) + { + _state.Data.AddOrUpdate(version); + _state.DownloadedRepositoryContent.Add(version.ContentKey); + } + + /// + /// Caches installed modification images with bounded parallelism. + /// + 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. + /// + private async Task CacheModificationImagesAsync( + LauncherContentVersion modification, + SemaphoreSlim semaphore, + CancellationToken cancellationToken) + { + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await _imageCache.CacheModificationImagesAsync(modification, Paths, cancellationToken) + .ConfigureAwait(false); + } + finally + { + semaphore.Release(); + } + } + + private async Task DownloadAdvertisingDataAsync( + RemoteAdvertisingReference advertisingData, + CancellationToken cancellationToken) + { + _state.Advertising = await _remoteCatalogClient.DownloadAdvertisingInfoAsync( + advertisingData.ManifestUrl, + cancellationToken).ConfigureAwait(false); + if (_state.Advertising is null) + { + return; + } + + await _imageCache.CacheAdvertisingImagesAsync(advertisingData, Paths, cancellationToken).ConfigureAwait(false); + } + + private static Dictionary ToManifestDictionary( + IEnumerable manifests) + { + var result = new Dictionary(); + foreach (RemoteModificationManifest manifest in manifests) + { + var key = LauncherContentKey.ForModificationName(manifest.Content.Name); + result.TryAdd(key, manifest); + } + + return result; + } + + private void AddRemoteModificationManifest(RemoteModificationManifest manifest) + { + var key = LauncherContentKey.ForModificationName(manifest.Content.Name); + _state.ModificationsAndAddons.TryAdd(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}.", + _state.RepositoryData is not null, + _state.Data.Modifications.Count, + _state.Data.Patches.Count, + _state.Data.Addons.Count, + CountVersions(_state.Data), + RepositoryModificationNames?.Count ?? 0); + } + + private static int CountVersions(LauncherData data) + { + return data.AllContent.Sum(content => content.Versions.Count); + } + + private sealed class CatalogSessionState + { + public CatalogSessionState(LauncherPaths? paths = null) + { + Paths = paths; + } + + public LauncherData Data { get; set; } = new(); + + public Dictionary ModificationsAndAddons { get; set; } = + []; + + public Dictionary RepositoryMetadataCache { get; } = []; + + public HashSet DownloadedModificationInfo { get; } = []; + + public HashSet DownloadedRepositoryContent { get; } = []; + + public LauncherContentVersion? Advertising { get; set; } + + public LauncherPaths? Paths { get; } + + public RemoteLauncherCatalog? RepositoryData { get; set; } + + public IReadOnlyList? RepositoryModificationNames { get; set; } + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentStateMapper.cs b/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentStateMapper.cs new file mode 100644 index 00000000..0849b292 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/LauncherContentStateMapper.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Maps compact legacy-compatible launcher content state to and from the active catalog. +/// +internal static class LauncherContentStateMapper +{ + /// + /// Rebuilds the catalog from persisted state, restoring each version's cached palette when one is supplied. + /// + /// The persisted local content state. + /// + /// The palette cache, or to rebuild without palettes. Persisted state deliberately + /// holds no remote manifest metadata, so a themed launcher only survives an offline restart because the + /// palette is restored here from its own cache. + /// + public static LauncherData ToLauncherData(LauncherContentState state, IModificationThemeCache? themeCache = null) + { + ArgumentNullException.ThrowIfNull(state); + + var launcherData = new LauncherData(); + AddStoredVersions(launcherData, state.Modifications, ModificationType.Mod, themeCache); + AddStoredVersions(launcherData, state.Addons, ModificationType.Addon, themeCache); + AddStoredVersions(launcherData, state.Patches, ModificationType.Patch, themeCache); + return launcherData; + } + + public static LauncherContentState ToLauncherContentState(LauncherData launcherData) + { + ArgumentNullException.ThrowIfNull(launcherData); + + return new LauncherContentState + { + Modifications = ToEntryStates(launcherData.Modifications, ModificationType.Mod), + Addons = ToEntryStates(launcherData.Addons, ModificationType.Addon), + Patches = ToEntryStates(launcherData.Patches, ModificationType.Patch) + }; + } + + private static LauncherContentVersionState ToVersionState( + LauncherContentVersion version, + ModificationType fallbackType, + bool entryIsSelected) + { + ArgumentNullException.ThrowIfNull(version); + + return new LauncherContentVersionState + { + ModificationType = ResolvePersistedContentType(version.ModificationType, fallbackType), + Name = version.Name ?? string.Empty, + Version = version.Version ?? string.Empty, + DependenceName = version.ParentContentName ?? string.Empty, + Installed = version.Installation.Installed, + IsSelected = entryIsSelected && version.Installation.IsSelected, + DownloadSuspended = version.Installation.DownloadSuspended, + SuspendedProgressPercentage = version.Installation.SuspendedProgressPercentage, + ContentSourceKind = version.Installation.ContentSourceKind + }; + } + + private static void AddStoredVersions( + LauncherData launcherData, + IEnumerable entries, + ModificationType fallbackType, + IModificationThemeCache? themeCache) + { + foreach (LauncherContentEntryState entry in entries ?? []) + { + LauncherContent? storedModification = null; + foreach (LauncherContentVersionState version in entry.ModificationVersions ?? + []) + { + LauncherContentVersion modificationVersion = ToModificationVersion( + entry, + version, + fallbackType, + themeCache); + launcherData.AddOrUpdate(modificationVersion); + storedModification ??= launcherData.FindContent(modificationVersion.ContentKey); + } + + if (storedModification != null) + { + storedModification.IsSelected = entry.IsSelected; + storedModification.NumberInList = entry.NumberInList; + } + } + } + + private static LauncherContentVersion ToModificationVersion( + LauncherContentEntryState entry, + LauncherContentVersionState version, + ModificationType fallbackType, + IModificationThemeCache? themeCache) + { + ModificationType contentType = ResolveContentType( + version.ModificationType, + entry.ModificationType, + fallbackType); + + var installation = new LauncherContentInstallation + { + Installed = version.Installed || entry.Installed, + IsSelected = entry.IsSelected && version.IsSelected, + DownloadSuspended = version.DownloadSuspended, + SuspendedProgressPercentage = version.SuspendedProgressPercentage, + ContentSourceKind = version.ContentSourceKind + }; + string contentName = CoalesceStateText(version.Name, entry.Name); + string contentVersion = version.Version ?? string.Empty; + string parentContentName = CoalesceStateText(version.DependenceName, entry.DependenceName); + var contentKey = new LauncherContentKey( + contentType, + parentContentName, + contentName, + contentVersion); + return new LauncherContentVersion(installation) + { + ModificationType = contentType, + Name = contentName, + Version = contentVersion, + ParentContentName = parentContentName, + Theme = themeCache?.Load(contentKey) + }; + } + + private static List ToEntryStates( + IEnumerable modifications, + ModificationType fallbackType) + { + var entries = new List(); + foreach (LauncherContent modification in modifications ?? []) + { + var versions = modification.Versions + .Where(version => ShouldPersistVersion(version, fallbackType)) + .Select(version => ToVersionState(version, fallbackType, modification.IsSelected)) + .ToList(); + + if (versions.Count == 0) + { + continue; + } + + entries.Add(new LauncherContentEntryState + { + ModificationType = ResolvePersistedContentType(modification.ModificationType, fallbackType), + Name = modification.Name ?? string.Empty, + DependenceName = modification.ContentKey.ParentIdentity, + Installed = modification.Installed, + IsSelected = modification.IsSelected, + NumberInList = modification.NumberInList, + ModificationVersions = versions + }); + } + + return entries; + } + + private static bool ShouldPersistVersion( + LauncherContentVersion version, + ModificationType fallbackType) + { + return version.Installation.Installed || + version.Installation.IsSelected || + // A suspended download has partial content on disk but is not installed yet, so it has to persist + // on its own merit or the next session would forget it and start over. + version.Installation.DownloadSuspended || + (fallbackType == ModificationType.Mod && + version.EffectiveContentSourceKind.IsManagedRemote()); + } + + private static string CoalesceStateText(string value, string fallback) + { + return !string.IsNullOrWhiteSpace(value) ? value : fallback ?? string.Empty; + } + + private static ModificationType ResolveContentType( + ModificationType versionType, + ModificationType entryType, + ModificationType fallbackType) + { + if (versionType != ModificationType.Mod || fallbackType == ModificationType.Mod) + { + return versionType; + } + + return entryType != ModificationType.Mod ? entryType : fallbackType; + } + + private static ModificationType ResolvePersistedContentType( + ModificationType type, + ModificationType fallbackType) + { + return type switch + { + ModificationType.Addon or ModificationType.Patch => type, + _ => fallbackType + }; + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/LauncherLocalContentReconciler.cs b/GenLauncherGO.Infrastructure/Mods/Services/LauncherLocalContentReconciler.cs new file mode 100644 index 00000000..05728b54 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/LauncherLocalContentReconciler.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; +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 +{ + private readonly ILocalLauncherContentService _localContentService; + + private readonly ILogger _logger; + + public LauncherLocalContentReconciler( + ILocalLauncherContentService localContentService, + ILogger logger) + { + _localContentService = localContentService ?? throw new ArgumentNullException(nameof(localContentService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public void Reconcile( + LauncherData launcherData, + IReadOnlyCollection downloadedReposContent, + LauncherPaths paths) + { + ArgumentNullException.ThrowIfNull(launcherData); + ArgumentNullException.ThrowIfNull(downloadedReposContent); + ArgumentNullException.ThrowIfNull(paths); + + _localContentService.DeleteEmptyPackageBackupDirectories(paths); + IReadOnlyList installedVersions = + _localContentService.FindInstalledVersions(paths); + + AddUnregisteredModifications(launcherData, installedVersions); + (int MarkedNotInstalledCount, int RemovedCount) changes = DeleteOutdatedModifications( + launcherData, + downloadedReposContent, + installedVersions, + paths); + _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( + LauncherContentKey contentKey, + LauncherPaths paths) + { + ArgumentNullException.ThrowIfNull(paths); + + _localContentService.DeleteVersion(paths, contentKey); + } + + public void DiscardVersion( + LauncherData launcherData, + LauncherContentKey contentKey, + LauncherPaths paths) + { + ArgumentNullException.ThrowIfNull(launcherData); + ArgumentNullException.ThrowIfNull(paths); + + _localContentService.DeleteVersion(paths, contentKey); + launcherData.DeleteVersion(contentKey); + _localContentService.DeleteImagesIfUnused(paths, contentKey, launcherData); + } + + public void DiscardContent( + LauncherData launcherData, + LauncherContentKey contentKey, + LauncherPaths paths) + { + ArgumentNullException.ThrowIfNull(launcherData); + ArgumentNullException.ThrowIfNull(paths); + + _localContentService.DeleteContent(paths, contentKey); + launcherData.DeleteContent(contentKey); + _localContentService.DeleteImagesIfUnused(paths, contentKey, launcherData); + } + + private static void AddUnregisteredModifications( + LauncherData launcherData, + IEnumerable installedVersions) + { + foreach (LauncherContentVersion version in installedVersions) + { + launcherData.AddOrUpdate(version); + } + } + + /// + /// Removes local-only catalog entries whose folders no longer contain files. + /// + private (int MarkedNotInstalledCount, int RemovedCount) DeleteOutdatedModifications( + LauncherData launcherData, + IReadOnlyCollection downloadedReposContent, + IReadOnlyCollection installedVersions, + LauncherPaths paths) + { + var installedVersionIds = installedVersions + .Select(version => version.ContentKey) + .ToHashSet(); + int markedNotInstalledCount = 0; + int removedCount = 0; + + IReadOnlyList contentVersions = launcherData.AllContent + .SelectMany(content => content.Versions) + .DistinctBy(version => version.ContentKey) + .ToList(); + + foreach (LauncherContentVersion version in contentVersions) + { + (bool markedNotInstalled, bool removed) = CheckContentExistence( + launcherData, + downloadedReposContent, + version, + paths, + installedVersionIds); + if (markedNotInstalled) + { + markedNotInstalledCount++; + } + + if (removed) + { + removedCount++; + } + } + + return (markedNotInstalledCount, removedCount); + } + + /// + /// Removes or marks a content version when the local folder no longer contains files. + /// + private (bool MarkedNotInstalled, bool Removed) CheckContentExistence( + LauncherData launcherData, + IReadOnlyCollection downloadedReposContent, + LauncherContentVersion modificationVersion, + LauncherPaths paths, + HashSet installedVersionIds) + { + if (installedVersionIds.Contains(modificationVersion.ContentKey)) + { + return (false, false); + } + + if (downloadedReposContent.Contains(modificationVersion.ContentKey) || + modificationVersion.EffectiveContentSourceKind.IsManagedRemote()) + { + if (modificationVersion.Installation.Installed) + { + modificationVersion.Installation.Installed = false; + return (true, false); + } + } + else + { + launcherData.DeleteVersion(modificationVersion.ContentKey); + _localContentService.DeleteImagesIfUnused(paths, modificationVersion.ContentKey, launcherData); + return (false, true); + } + + return (false, false); + } + +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/RemoteLauncherCatalogClient.cs b/GenLauncherGO.Infrastructure/Mods/Services/RemoteLauncherCatalogClient.cs new file mode 100644 index 00000000..29d7ad1e --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/RemoteLauncherCatalogClient.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Support; +using GenLauncherGO.Infrastructure.Remote.Contracts; +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 +{ + private const int MaxConcurrentManifestReads = 6; + + private readonly ILogger _logger; + + private readonly IRemoteYamlDocumentReader _yamlDocumentReader; + + 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); + + LegacyLauncherCatalogDocument? 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 = installedModNames + .Select(LauncherContentKey.ForModificationName) + .ToHashSet(); + var installedModData = catalog.Modifications + .Where(reference => + string.IsNullOrEmpty(reference.Name) || + downloadedModNames.Contains(LauncherContentKey.ForModificationName(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(); + foreach (RemoteModificationManifest? result in results) + { + if (result is null) + { + continue; + } + + var key = LauncherContentKey.ForModificationName(result.Content.Name); + mods.TryAdd(key, 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 => LauncherContentKey.ForModificationName(data.Name) == + LauncherContentKey.ForModificationName(name)); + + return await DownloadModDataAsync(reference, cancellationToken).ConfigureAwait(false); + } + + public async Task ReadChildManifestsAsync( + IEnumerable manifestUrls, + string? parentContentName, + CancellationToken cancellationToken) + { + using var semaphore = new SemaphoreSlim(MaxConcurrentManifestReads); + LauncherContentVersion?[] contentVersions = await Task.WhenAll( + (manifestUrls ?? new List()).Select(url => ReadChildManifestIfAvailableAsync( + url, + parentContentName, + semaphore, + cancellationToken))).ConfigureAwait(false); + + IReadOnlyList successfulVersions = + contentVersions.Where(version => version != null).ToList()!; + return new RemoteChildManifestLoadResult( + successfulVersions, + contentVersions.Count(version => version is null)); + } + + /// + /// Downloads one modification manifest while respecting the startup refresh concurrency limit. + /// + private async Task DownloadModDataIfAvailableAsync( + RemoteCatalogModificationReference reference, + SemaphoreSlim semaphore, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(reference); + + return await ReadWithConcurrencyLimitIfAvailableAsync( + semaphore, + token => DownloadModDataAsync(reference, token), + exception => + _logger.LogWarning( + "Failed to download remote modification manifest for {ModificationName}: {FailureReason}.", + reference.Name, + exception.Message), + cancellationToken).ConfigureAwait(false); + } + + /// + /// Reads one child manifest while respecting the startup refresh concurrency limit. + /// + private async Task ReadChildManifestIfAvailableAsync( + string url, + string? parentContentName, + SemaphoreSlim semaphore, + CancellationToken cancellationToken) + { + return await ReadWithConcurrencyLimitIfAvailableAsync( + semaphore, + token => ReadModificationYamlAsync(url, parentContentName, token), + exception => + _logger.LogWarning( + "Failed to read child modification manifest: {FailureReason}.", + exception.Message), + cancellationToken).ConfigureAwait(false); + } + + private static async Task ReadWithConcurrencyLimitIfAvailableAsync( + SemaphoreSlim semaphore, + Func> read, + Action logFailure, + CancellationToken cancellationToken) + where T : class + { + await semaphore.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + return await ReadIfAvailableAsync(read, logFailure, cancellationToken).ConfigureAwait(false); + } + finally + { + semaphore.Release(); + } + } + + private static async Task ReadIfAvailableAsync( + Func> read, + Action logFailure, + CancellationToken cancellationToken) + where T : class + { + try + { + return await read(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + logFailure(exception); + return null; + } + } + + public async Task DownloadAdvertisingInfoAsync( + string manifestUrl, + CancellationToken cancellationToken) + { + return await ReadIfAvailableAsync( + token => ReadModificationYamlAsync(manifestUrl, null, token), + exception => + _logger.LogWarning( + "Failed to download advertising manifest: {FailureReason}.", + exception.Message), + cancellationToken).ConfigureAwait(false); + } + + private async Task DownloadModDataAsync( + RemoteCatalogModificationReference reference, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(reference); + + LauncherContentVersion modification = await ReadModificationYamlAsync( + reference.ManifestUrl, + null, + cancellationToken).ConfigureAwait(false); + return new RemoteModificationManifest( + modification, + reference.PatchManifestUrls, + reference.AddonManifestUrls); + } + + private async Task ReadModificationYamlAsync( + string documentUrl, + string? parentContentName, + CancellationToken cancellationToken) + { + LegacyContentManifest manifest = await _yamlDocumentReader.ReadYamlAsync( + new Uri(documentUrl, UriKind.Absolute), + cancellationToken).ConfigureAwait(false); + return RemoteLauncherCatalogMapper.ToLauncherContentVersion(manifest, parentContentName); + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Services/YamlLauncherContentStateStore.cs b/GenLauncherGO.Infrastructure/Mods/Services/YamlLauncherContentStateStore.cs new file mode 100644 index 00000000..a8c3599c --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Services/YamlLauncherContentStateStore.cs @@ -0,0 +1,49 @@ +using System; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Contracts; +using GenLauncherGO.Infrastructure.Mods.Models; +using GenLauncherGO.Infrastructure.Persistence.Services; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Mods.Services; + +/// +/// Stores launcher content state in a YAML-backed document. +/// +internal sealed class YamlLauncherContentStateStore : ILauncherContentStateStore +{ + private readonly IAtomicFileWriter _atomicFileWriter; + + private readonly ILogger> _logger; + + public YamlLauncherContentStateStore( + IAtomicFileWriter atomicFileWriter, + ILogger> logger) + { + _atomicFileWriter = atomicFileWriter ?? throw new ArgumentNullException(nameof(atomicFileWriter)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public LauncherContentState Load(LauncherPaths paths) + { + ArgumentNullException.ThrowIfNull(paths); + + return CreateDocumentStore(paths).Load(new LauncherContentState()); + } + + public void Save(LauncherPaths paths, LauncherContentState state) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(state); + + CreateDocumentStore(paths).Save(state); + } + + private IYamlDocumentStore CreateDocumentStore(LauncherPaths paths) + { + return new YamlDocumentStore( + paths.LauncherDataFilePath, + _atomicFileWriter, + _logger); + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Support/ModificationCacheFailure.cs b/GenLauncherGO.Infrastructure/Mods/Support/ModificationCacheFailure.cs new file mode 100644 index 00000000..acb12d97 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Support/ModificationCacheFailure.cs @@ -0,0 +1,16 @@ +using System; +using System.IO; + +namespace GenLauncherGO.Infrastructure.Mods.Support; + +/// +/// Classifies expected filesystem and path failures handled by modification image and palette cache boundaries. +/// +internal static class ModificationCacheFailure +{ + public static bool IsRecoverable(Exception exception) + { + return exception is InvalidDataException or IOException or UnauthorizedAccessException + or ArgumentException or NotSupportedException; + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Support/ModificationImageCachePath.cs b/GenLauncherGO.Infrastructure/Mods/Support/ModificationImageCachePath.cs new file mode 100644 index 00000000..4949e329 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Support/ModificationImageCachePath.cs @@ -0,0 +1,180 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; + +namespace GenLauncherGO.Infrastructure.Mods.Support; + +/// +/// Owns safe cached-image paths and the file naming convention shared by catalog downloads and integrity repair. +/// +internal static class ModificationImageCachePath +{ + private const string PathSubject = "Cached modification image paths"; + + private const string ImageDirectoryOwnerDescription = "the launcher-owned image directory"; + + private const string CacheDirectoryOwnerDescription = "the modification image cache directory"; + + public static string ResolveDirectory( + LauncherPaths paths, + ModificationType modificationType, + string modificationName) + { + return ResolvePath(paths, GetDirectoryPath(paths, modificationType, modificationName)); + } + + /// + /// Builds the lexical cache path without traversing it, so cleanup can safely unlink a reparse-point entry. + /// + public static string GetDirectoryPath( + LauncherPaths paths, + ModificationType modificationType, + string modificationName) + { + ArgumentNullException.ThrowIfNull(paths); + + return paths.GetModificationImagesDirectory(GetDirectoryName(modificationType, modificationName)); + } + + public static string ResolvePath(LauncherPaths paths, string imagePath) + { + ArgumentNullException.ThrowIfNull(paths); + + return FileSystemPathSafety.ResolveOwnedSubpath( + paths.ImagesDirectory, + imagePath, + PathSubject, + ImageDirectoryOwnerDescription); + } + + public static string ResolveImagePath( + LauncherPaths paths, + ModificationType modificationType, + string modificationName, + string imageFileName) + { + ArgumentNullException.ThrowIfNull(paths); + + return ResolvePath( + paths, + paths.GetModificationImageFilePath( + GetDirectoryName(modificationType, modificationName), + imageFileName)); + } + + public static string ResolveRemoteImagePath( + LauncherPaths paths, + ModificationType modificationType, + string modificationName, + string imageBaseName, + Uri sourceUri) + { + ArgumentNullException.ThrowIfNull(paths); + + return ResolveImagePath( + paths, + modificationType, + modificationName, + GetRemoteImageFileName(imageBaseName, sourceUri)); + } + + public static string ResolveRemoteImagePath( + string cacheDirectory, + string imageBaseName, + Uri sourceUri) + { + ArgumentException.ThrowIfNullOrWhiteSpace(cacheDirectory); + + return FileSystemPathSafety.ResolveOwnedSubpath( + cacheDirectory, + Path.Combine(cacheDirectory, GetRemoteImageFileName(imageBaseName, sourceUri)), + PathSubject, + CacheDirectoryOwnerDescription); + } + + /// + /// Enumerates the remote artwork published by one content version and the canonical base name of each asset. + /// + public static IReadOnlyList<(string Link, string BaseName, bool RequiresTheme)> GetRemoteAssetSources( + LauncherContentVersion version) + { + ArgumentNullException.ThrowIfNull(version); + + var sources = new List<(string Link, string BaseName, bool RequiresTheme)>(2); + if (!string.IsNullOrEmpty(version.UIImageSourceLink)) + { + sources.Add((version.UIImageSourceLink, version.Version, false)); + } + + string backgroundLink = version.Theme?.GenLauncherBackgroundImageLink ?? string.Empty; + if (backgroundLink.Length > 0) + { + sources.Add(( + backgroundLink, + LauncherContentTheme.ResolveBackgroundImageBaseName(version.Version), + true)); + } + + return sources; + } + + /// + /// Resolves the valid remote artwork destinations expected inside one modification cache directory. + /// + public static IReadOnlyList<(Uri SourceUri, string DestinationPath)> ResolveRemoteAssets( + LauncherContentVersion version, + string cacheDirectory) + { + ArgumentNullException.ThrowIfNull(version); + ArgumentException.ThrowIfNullOrWhiteSpace(cacheDirectory); + + var assets = new List<(Uri SourceUri, string DestinationPath)>(2); + foreach ((string link, string baseName, _) in GetRemoteAssetSources(version)) + { + if (Uri.TryCreate(link, UriKind.Absolute, out Uri? sourceUri)) + { + assets.Add((sourceUri, ResolveRemoteImagePath(cacheDirectory, baseName, sourceUri))); + } + } + + return assets; + } + + /// + /// Gets every cache-file base name owned by one version, including local palette data and optional artwork. + /// + public static IReadOnlyList GetOwnedBaseNames(LauncherContentVersion version) + { + ArgumentNullException.ThrowIfNull(version); + + return + [ + version.Version, + LauncherContentTheme.ResolveBackgroundImageBaseName(version.Version), + LauncherContentTheme.ResolveCacheBaseName(version.Version) + ]; + } + + private static string GetRemoteImageFileName(string imageBaseName, Uri sourceUri) + { + ArgumentNullException.ThrowIfNull(sourceUri); + + string extension = Path.GetExtension(sourceUri.LocalPath); + if (!LauncherContentFileTypes.IsImage(extension)) + { + extension = LauncherContentFileTypes.DefaultImageExtension; + } + + return imageBaseName + extension; + } + + private static string GetDirectoryName(ModificationType modificationType, string modificationName) + { + return modificationType == ModificationType.Advertising + ? modificationName.Trim(Path.GetInvalidFileNameChars()) + : modificationName; + } +} diff --git a/GenLauncherGO.Infrastructure/Mods/Support/RemoteLauncherCatalogMapper.cs b/GenLauncherGO.Infrastructure/Mods/Support/RemoteLauncherCatalogMapper.cs new file mode 100644 index 00000000..38f71352 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Mods/Support/RemoteLauncherCatalogMapper.cs @@ -0,0 +1,151 @@ +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Mods.Support; + +/// +/// Maps third-party backend manifest DTOs once into normalized launcher models. +/// +internal static class RemoteLauncherCatalogMapper +{ + /// + /// Maps a backend repository manifest to a normalized remote catalog. + /// + public static RemoteLauncherCatalog ToRemoteCatalog(LegacyLauncherCatalogDocument? repositoryData) + { + if (repositoryData is null) + { + return RemoteLauncherCatalog.Empty; + } + + // globalAddonsData is a vestigial backend field that neither the predecessor nor this launcher exposes. + return new RemoteLauncherCatalog( + ToAdvertisingReferences(repositoryData.AdvData), + ToModificationReferences(repositoryData.modDatas), + ToStringList(repositoryData.originalGameAddons), + ToStringList(repositoryData.originalGamePatches)); + } + + /// + /// Maps a backend content manifest directly to normalized domain metadata. + /// + public static LauncherContentVersion ToLauncherContentVersion( + LegacyContentManifest? manifest, + string? parentContentName = null) + { + if (manifest is null) + { + return new LauncherContentVersion + { + ParentContentName = parentContentName ?? string.Empty + }; + } + + string simpleDownloadLink = manifest.SimpleDownloadLink ?? string.Empty; + string s3HostLink = manifest.S3HostLink ?? string.Empty; + string s3BucketName = manifest.S3BucketName ?? string.Empty; + string s3FolderName = manifest.S3FolderName ?? string.Empty; + var installation = new LauncherContentInstallation + { + ContentSourceKind = LauncherContentVersion.ResolveContentSourceKind( + s3HostLink, + s3BucketName, + s3FolderName, + simpleDownloadLink, + manifest.ContentSourceKind) + }; + + return new LauncherContentVersion(installation) + { + ModificationType = manifest.ModificationType, + Name = manifest.Name ?? string.Empty, + Version = manifest.Version ?? string.Empty, + SimpleDownloadLink = simpleDownloadLink, + UIImageSourceLink = manifest.UIImageSourceLink ?? string.Empty, + DiscordLink = manifest.DiscordLink ?? string.Empty, + ModDBLink = manifest.ModDBLink ?? string.Empty, + NewsLink = manifest.NewsLink ?? string.Empty, + ParentContentName = parentContentName ?? manifest.DependenceName ?? string.Empty, + S3HostLink = s3HostLink, + S3BucketName = s3BucketName, + S3FolderName = s3FolderName, + S3HostPublicKey = manifest.S3HostPublicKey ?? string.Empty, + S3HostSecretKey = manifest.S3HostSecretKey ?? string.Empty, + NetworkInfo = manifest.NetworkInfo ?? string.Empty, + Deprecated = manifest.Deprecated, + SupportLink = manifest.SupportLink ?? string.Empty, + Theme = ToLauncherContentTheme(manifest.ColorsInformation) + }; + } + + /// + /// Maps the backend's per-modification palette, dropping a block that carries nothing usable. + /// + /// + /// Slots stay independent on purpose: a modification may publish only the few colours it cares about, and the + /// launcher fills the rest from the active game's palette rather than rejecting the block. + /// + private static LauncherContentTheme? ToLauncherContentTheme(LegacyContentThemeManifest? theme) + { + if (theme is null) + { + return null; + } + + var mapped = new LauncherContentTheme + { + GenLauncherBorderColor = Normalize(theme.GenLauncherBorderColor), + GenLauncherInactiveBorder = Normalize(theme.GenLauncherInactiveBorder), + GenLauncherInactiveBorder2 = Normalize(theme.GenLauncherInactiveBorder2), + GenLauncherActiveColor = Normalize(theme.GenLauncherActiveColor), + GenLauncherDarkFillColor = Normalize(theme.GenLauncherDarkFillColor), + GenLauncherDarkBackGround = Normalize(theme.GenLauncherDarkBackGround), + GenLauncherLightBackGround = Normalize(theme.GenLauncherLightBackGround), + GenLauncherDefaultTextColor = Normalize(theme.GenLauncherDefaultTextColor), + GenLauncherDownloadTextColor = Normalize(theme.GenLauncherDownloadTextColor), + GenLauncherListBoxSelectionColor1 = Normalize(theme.GenLauncherListBoxSelectionColor1), + GenLauncherListBoxSelectionColor2 = Normalize(theme.GenLauncherListBoxSelectionColor2), + GenLauncherButtonSelectionColor = Normalize(theme.GenLauncherButtonSelectionColor), + GenLauncherBackgroundImageLink = Normalize(theme.GenLauncherBackgroundImageLink) + }; + + return mapped.HasValues ? mapped : null; + } + + private static string Normalize(string? value) + { + return value?.Trim() ?? string.Empty; + } + + private static IReadOnlyList ToModificationReferences( + IEnumerable? modificationReferences) + { + return (modificationReferences ?? []) + .Select(reference => new RemoteCatalogModificationReference( + reference.ModName, + reference.ModLink, + ToStringList(reference.ModPatches), + ToStringList(reference.ModAddons))) + .ToList(); + } + + private static IReadOnlyList ToAdvertisingReferences( + IEnumerable? advertisingReferences) + { + return (advertisingReferences ?? []) + .Select(reference => new RemoteAdvertisingReference( + reference.ModName, + reference.ModLink, + ToStringList(reference.ImagesData))) + .ToList(); + } + + private static IReadOnlyList ToStringList(IEnumerable? values) + { + return (values ?? []) + .OfType() + .ToList(); + } +} diff --git a/GenLauncherGO.Infrastructure/Persistence/Services/AtomicFileWriter.cs b/GenLauncherGO.Infrastructure/Persistence/Services/AtomicFileWriter.cs new file mode 100644 index 00000000..e5fb5410 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Persistence/Services/AtomicFileWriter.cs @@ -0,0 +1,193 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Infrastructure.Common; + +namespace GenLauncherGO.Infrastructure.Persistence.Services; + +/// +/// Writes complete text files through a same-directory temporary file and atomic commit. +/// +internal sealed class AtomicFileWriter : IAtomicFileWriter +{ + public void WriteText(string destinationPath, string contents) + { + ArgumentNullException.ThrowIfNull(contents); + (string fullDestinationPath, string temporaryPath) = PrepareWrite(destinationPath); + try + { + WriteTemporaryFile(temporaryPath, contents); + CommitTemporaryFile(temporaryPath, fullDestinationPath); + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + + public async Task WriteAsync( + string destinationPath, + Func writeTemporaryFileAsync, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(writeTemporaryFileAsync); + cancellationToken.ThrowIfCancellationRequested(); + (string fullDestinationPath, string temporaryPath) = PrepareWrite(destinationPath); + try + { + await WriteTemporaryFileAsync( + temporaryPath, + writeTemporaryFileAsync, + cancellationToken) + .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + // Once the atomic replace or move begins, it must run to completion so callers never observe + // an ambiguous destination state. + CommitTemporaryFile(temporaryPath, fullDestinationPath); + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + + public async Task WriteFileIfMissingAsync( + string destinationPath, + Func writeTemporaryFileAsync, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(writeTemporaryFileAsync); + cancellationToken.ThrowIfCancellationRequested(); + (string fullDestinationPath, string temporaryPath) = PrepareWrite(destinationPath); + if (File.Exists(fullDestinationPath)) + { + return false; + } + + try + { + await writeTemporaryFileAsync(temporaryPath, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + return CommitTemporaryFileIfMissing(temporaryPath, fullDestinationPath); + } + finally + { + if (File.Exists(temporaryPath)) + { + File.Delete(temporaryPath); + } + } + } + + private static (string DestinationPath, string TemporaryPath) PrepareWrite(string destinationPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(destinationPath); + + string fullDestinationPath = LexicalPath.NormalizeFullPath(destinationPath); + string destinationDirectory = Path.GetDirectoryName(fullDestinationPath) + ?? throw new InvalidOperationException( + "Atomic document paths must have a parent directory."); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + destinationDirectory, + "Atomic document directories"); + Directory.CreateDirectory(destinationDirectory); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + destinationDirectory, + "Atomic document directories"); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + fullDestinationPath, + "Atomic document paths"); + + string temporaryPath = Path.Combine( + destinationDirectory, + $".{Path.GetFileName(fullDestinationPath)}.{Guid.NewGuid():N}.tmp"); + return (fullDestinationPath, temporaryPath); + } + + private static void WriteTemporaryFile(string temporaryPath, string contents) + { + byte[] bytes = new UTF8Encoding(false).GetBytes(contents); + using FileStream stream = new( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 4096, + FileOptions.WriteThrough); + stream.Write(bytes, 0, bytes.Length); + stream.Flush(true); + } + + private static async Task WriteTemporaryFileAsync( + string temporaryPath, + Func writeTemporaryFileAsync, + CancellationToken cancellationToken) + { + await using FileStream stream = new( + temporaryPath, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + 4096, + FileOptions.Asynchronous | FileOptions.WriteThrough); + await writeTemporaryFileAsync(stream, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + + // FlushAsync drains managed buffers with cancellation support. Flush(true) retains the existing + // durable-to-disk guarantee before the atomic commit. + stream.Flush(true); + } + + private static void CommitTemporaryFile(string temporaryPath, string destinationPath) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + destinationPath, + "Atomic document paths"); + if (File.Exists(destinationPath)) + { + File.Replace(temporaryPath, destinationPath, null, true); + return; + } + + try + { + File.Move(temporaryPath, destinationPath); + } + catch (IOException) when (File.Exists(destinationPath)) + { + File.Replace(temporaryPath, destinationPath, null, true); + } + } + + private static bool CommitTemporaryFileIfMissing(string temporaryPath, string destinationPath) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + destinationPath, + "Atomic file paths"); + if (File.Exists(destinationPath)) + { + return false; + } + + try + { + File.Move(temporaryPath, destinationPath); + return true; + } + catch (IOException) when (File.Exists(destinationPath)) + { + return false; + } + } +} diff --git a/GenLauncherGO.Infrastructure/Persistence/Services/IAtomicFileWriter.cs b/GenLauncherGO.Infrastructure/Persistence/Services/IAtomicFileWriter.cs new file mode 100644 index 00000000..89f8c02a --- /dev/null +++ b/GenLauncherGO.Infrastructure/Persistence/Services/IAtomicFileWriter.cs @@ -0,0 +1,41 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Infrastructure.Persistence.Services; + +/// +/// Commits complete files atomically within their destination directory. +/// +internal interface IAtomicFileWriter +{ + /// + /// Writes and durably flushes a temporary file before atomically committing it to the destination path. + /// + void WriteText(string destinationPath, string contents); + + /// + /// Writes and durably flushes a temporary file asynchronously before atomically committing it to the destination path. + /// + /// The final document path. + /// + /// The operation that writes the complete document to the temporary stream and leaves the stream open. + /// + /// + /// A token that cancels temporary-file writing and flushing. The final atomic commit is not cancellable once started. + /// + Task WriteAsync( + string destinationPath, + Func writeTemporaryFileAsync, + CancellationToken cancellationToken); + + /// + /// Writes to a temporary sibling path and atomically publishes it only when the destination is still missing. + /// + /// when this call published the file; otherwise, . + Task WriteFileIfMissingAsync( + string destinationPath, + Func writeTemporaryFileAsync, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Persistence/Services/IYamlDocumentStore.cs b/GenLauncherGO.Infrastructure/Persistence/Services/IYamlDocumentStore.cs new file mode 100644 index 00000000..85743860 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Persistence/Services/IYamlDocumentStore.cs @@ -0,0 +1,19 @@ +namespace GenLauncherGO.Infrastructure.Persistence.Services; + +internal interface IYamlDocumentStore + where TDocument : class +{ + bool DocumentExists { get; } + + /// + /// Loads the document from disk. + /// + /// The loaded document, or . + TDocument Load(TDocument defaultDocument); + + /// + /// Saves the document to disk. + /// + /// Persistence failures are logged and propagated to the caller. + 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..50058195 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Persistence/Services/YamlDocumentStore.cs @@ -0,0 +1,83 @@ +using System; +using System.IO; +using GenLauncherGO.Infrastructure.Common; +using Microsoft.Extensions.Logging; +using YamlDotNet.Serialization; + +namespace GenLauncherGO.Infrastructure.Persistence.Services; + +internal sealed class YamlDocumentStore : IYamlDocumentStore + where TDocument : class +{ + private readonly IAtomicFileWriter _atomicFileWriter; + private readonly string _documentFilePath; + + private readonly ILogger> _logger; + + public YamlDocumentStore( + string documentFilePath, + IAtomicFileWriter atomicFileWriter, + ILogger> logger) + { + ArgumentException.ThrowIfNullOrWhiteSpace(documentFilePath); + + _documentFilePath = documentFilePath; + _atomicFileWriter = atomicFileWriter ?? throw new ArgumentNullException(nameof(atomicFileWriter)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public bool DocumentExists => File.Exists(_documentFilePath); + + public TDocument Load(TDocument defaultDocument) + { + ArgumentNullException.ThrowIfNull(defaultDocument); + + if (!File.Exists(_documentFilePath)) + { + return defaultDocument; + } + + try + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + _documentFilePath, + "YAML document paths"); + IDeserializer deserializer = new DeserializerBuilder() + .IgnoreUnmatchedProperties() + .Build(); + + using TextReader reader = File.OpenText(_documentFilePath); + return deserializer.Deserialize(reader) ?? defaultDocument; + } + catch (Exception exception) + { + _logger.LogWarning( + exception, + "Failed to load {DocumentType} from {DocumentFileName}.", + typeof(TDocument).Name, + Path.GetFileName(_documentFilePath)); + return defaultDocument; + } + } + + public void Save(TDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + try + { + ISerializer serializer = new Serializer(); + string yaml = serializer.Serialize(document); + _atomicFileWriter.WriteText(_documentFilePath, yaml); + } + catch (Exception exception) + { + _logger.LogError( + exception, + "Failed to save {DocumentType} to {DocumentFileName}.", + typeof(TDocument).Name, + Path.GetFileName(_documentFilePath)); + throw; + } + } +} diff --git a/GenLauncherGO.Infrastructure/Properties/AssemblyInfo.cs b/GenLauncherGO.Infrastructure/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..33f07f82 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Properties/AssemblyInfo.cs @@ -0,0 +1,9 @@ +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +[assembly: InternalsVisibleTo("GenLauncherGO.Tests")] + +// Every P/Invoke in this assembly targets kernel32 or user32, so restricting +// resolution to System32 cannot fail to find them, and it removes the search of +// the application directory that a planted DLL of the same name would exploit. +[assembly: DefaultDllImportSearchPaths(DllImportSearchPath.System32)] diff --git a/GenLauncherGO.Infrastructure/Remote/Contracts/IRemoteAssetDownloader.cs b/GenLauncherGO.Infrastructure/Remote/Contracts/IRemoteAssetDownloader.cs new file mode 100644 index 00000000..04a179e1 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Remote/Contracts/IRemoteAssetDownloader.cs @@ -0,0 +1,16 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Infrastructure.Remote.Contracts; + +internal interface IRemoteAssetDownloader +{ + /// + /// Downloads an asset only when the destination file is not already present. + /// + Task DownloadIfMissingAsync( + Uri sourceUri, + string destinationFilePath, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Remote/Contracts/IRemoteYamlDocumentReader.cs b/GenLauncherGO.Infrastructure/Remote/Contracts/IRemoteYamlDocumentReader.cs new file mode 100644 index 00000000..ceda8f6a --- /dev/null +++ b/GenLauncherGO.Infrastructure/Remote/Contracts/IRemoteYamlDocumentReader.cs @@ -0,0 +1,10 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Infrastructure.Remote.Contracts; + +internal interface IRemoteYamlDocumentReader +{ + Task ReadYamlAsync(Uri documentUri, CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Remote/HttpHeadFallbackRequest.cs b/GenLauncherGO.Infrastructure/Remote/HttpHeadFallbackRequest.cs new file mode 100644 index 00000000..17946d16 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Remote/HttpHeadFallbackRequest.cs @@ -0,0 +1,66 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Infrastructure.Remote; + +/// +/// Sends a headers-only HEAD request and falls back to GET when the endpoint or response reader requires it. +/// +internal static class HttpHeadFallbackRequest +{ + public static async Task SendAsync( + HttpClient httpClient, + Uri endpointUri, + Func readResponse, + Func shouldFallback, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(httpClient); + ArgumentNullException.ThrowIfNull(endpointUri); + ArgumentNullException.ThrowIfNull(readResponse); + ArgumentNullException.ThrowIfNull(shouldFallback); + + (TResult HeadResult, bool HeadUnsupported) head = await SendAsync( + httpClient, + endpointUri, + HttpMethod.Head, + readResponse, + cancellationToken).ConfigureAwait(false); + if (!head.HeadUnsupported && !shouldFallback(head.HeadResult)) + { + return head.HeadResult; + } + + (TResult getResult, _) = await SendAsync( + httpClient, + endpointUri, + HttpMethod.Get, + readResponse, + cancellationToken).ConfigureAwait(false); + return getResult; + } + + private static async Task<(TResult Result, bool HeadUnsupported)> SendAsync( + HttpClient httpClient, + Uri endpointUri, + HttpMethod method, + Func readResponse, + CancellationToken cancellationToken) + { + using HttpRequestMessage request = new(method, endpointUri); + using HttpResponseMessage response = await httpClient.SendAsync( + request, + HttpCompletionOption.ResponseHeadersRead, + cancellationToken).ConfigureAwait(false); + + bool headUnsupported = method == HttpMethod.Head && + response.StatusCode is HttpStatusCode.MethodNotAllowed or + HttpStatusCode.NotImplemented; + return headUnsupported + ? (default!, true) + : (readResponse(response), false); + } +} diff --git a/GenLauncherGO.Infrastructure/Remote/HttpRemoteAssetDownloader.cs b/GenLauncherGO.Infrastructure/Remote/HttpRemoteAssetDownloader.cs new file mode 100644 index 00000000..bf94440a --- /dev/null +++ b/GenLauncherGO.Infrastructure/Remote/HttpRemoteAssetDownloader.cs @@ -0,0 +1,69 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Persistence.Services; +using GenLauncherGO.Infrastructure.Remote.Contracts; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Remote; + +internal sealed class HttpRemoteAssetDownloader : IRemoteAssetDownloader +{ + private readonly IResumableFileDownloader _fileDownloader; + private readonly IAtomicFileWriter _atomicFileWriter; + private readonly ILogger _logger; + + public HttpRemoteAssetDownloader( + IResumableFileDownloader fileDownloader, + IAtomicFileWriter atomicFileWriter, + ILogger logger) + { + _fileDownloader = fileDownloader ?? throw new ArgumentNullException(nameof(fileDownloader)); + _atomicFileWriter = atomicFileWriter ?? throw new ArgumentNullException(nameof(atomicFileWriter)); + _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. + /// + public async Task DownloadIfMissingAsync( + Uri sourceUri, + string destinationFilePath, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(sourceUri); + ArgumentException.ThrowIfNullOrWhiteSpace(destinationFilePath); + + if (File.Exists(destinationFilePath)) + { + return; + } + + string legacyTemporaryFilePath = destinationFilePath + ".download"; + if (File.Exists(legacyTemporaryFilePath)) + { + File.Delete(legacyTemporaryFilePath); + _logger.LogDebug( + "Deleted stale remote asset download file {FileName}.", + Path.GetFileName(legacyTemporaryFilePath)); + } + + bool committed = await _atomicFileWriter.WriteFileIfMissingAsync( + destinationFilePath, + (temporaryFilePath, token) => _fileDownloader.DownloadFileAsync( + new DownloadFileRequest(sourceUri, temporaryFilePath, Resume: false), + null, + token), + cancellationToken).ConfigureAwait(false); + if (committed) + { + _logger.LogDebug( + "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..0c4d7f02 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Remote/HttpRemoteConnectionProbe.cs @@ -0,0 +1,65 @@ +using System; +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. +/// +internal sealed class HttpRemoteConnectionProbe : IRemoteConnectionProbe +{ + private static readonly HttpClient _sharedHttpClient = + SharedHttpClientFactory.Create(TimeSpan.FromSeconds(30)); + + private readonly HttpClient _httpClient; + private readonly ILogger _logger; + + 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. + /// + public async Task CanConnectAsync(Uri endpointUri, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(endpointUri); + + try + { + return await HttpHeadFallbackRequest.SendAsync( + _httpClient, + endpointUri, + static response => response.IsSuccessStatusCode, + static connected => !connected, + cancellationToken).ConfigureAwait(false); + } + catch (HttpRequestException exception) + { + _logger.LogWarning( + exception, + "Remote connection probe failed for {Scheme}://{Host}.", + endpointUri.Scheme, + endpointUri.Host); + return false; + } + catch (TaskCanceledException exception) when (!cancellationToken.IsCancellationRequested) + { + _logger.LogWarning( + exception, + "Remote connection probe timed out for {Scheme}://{Host}.", + endpointUri.Scheme, + endpointUri.Host); + return false; + } + } + +} diff --git a/GenLauncherGO.Infrastructure/Remote/HttpRemoteYamlDocumentReader.cs b/GenLauncherGO.Infrastructure/Remote/HttpRemoteYamlDocumentReader.cs new file mode 100644 index 00000000..d6f481c4 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Remote/HttpRemoteYamlDocumentReader.cs @@ -0,0 +1,70 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Remote.Contracts; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using YamlDotNet.Serialization; + +namespace GenLauncherGO.Infrastructure.Remote; + +/// +/// Reads YAML documents over HTTP. +/// +internal 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; + + 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 exception) + { + _logger.LogDebug( + exception, + "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..9796cf2a --- /dev/null +++ b/GenLauncherGO.Infrastructure/Remote/SharedHttpClientFactory.cs @@ -0,0 +1,34 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; + +namespace GenLauncherGO.Infrastructure.Remote; + +internal static class SharedHttpClientFactory +{ + /// + /// Creates an HTTP client with pooled connections, no automatic decompression, and a GenLauncherGO user agent. + /// + 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..d63b73e6 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Settings/Composition/SettingsInfrastructureServiceCollectionExtensions.cs @@ -0,0 +1,40 @@ +using System; +using GenLauncherGO.Core.Settings.Contracts; +using GenLauncherGO.Infrastructure.Persistence.Services; +using GenLauncherGO.Infrastructure.Settings.Models; +using GenLauncherGO.Infrastructure.Settings.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Settings.Composition; + +public static class SettingsInfrastructureServiceCollectionExtensions +{ + public static IServiceCollection AddGenLauncherGoSettingsInfrastructure( + this IServiceCollection services, + string preferencesFilePath) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentException.ThrowIfNullOrWhiteSpace(preferencesFilePath); + + services.TryAddSingleton(); + services.AddSingleton>(serviceProvider => + new YamlDocumentStore( + preferencesFilePath, + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService>>())); + services.AddSingleton>(serviceProvider => + new YamlDocumentStore( + preferencesFilePath, + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService>>())); + services.AddSingleton>(serviceProvider => + new YamlDocumentStore( + preferencesFilePath, + serviceProvider.GetRequiredService(), + serviceProvider.GetRequiredService>>())); + services.AddSingleton(); + return services; + } +} diff --git a/GenLauncherGO.Infrastructure/Settings/Models/LauncherPreferencesDocument.cs b/GenLauncherGO.Infrastructure/Settings/Models/LauncherPreferencesDocument.cs new file mode 100644 index 00000000..88bdfc08 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Settings/Models/LauncherPreferencesDocument.cs @@ -0,0 +1,117 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Infrastructure.Settings.Models; + +/// +/// Reads only the schema marker so unsupported documents can be rejected before binding their version-specific shape. +/// +internal sealed class LauncherPreferencesSchemaDocument +{ + /// + /// Tracks whether YAML binding encountered the schema key. The load fallback explicitly assigns + /// , + /// so distinguishes an unreadable document from valid legacy YAML that omits the key. + /// + public int? SchemaVersion + { + get; + set + { + field = value; + HasSchemaVersion = true; + } + } + + internal bool HasSchemaVersion { get; private set; } +} + +/// +/// Defines the exact standalone preferences YAML schema at the persistence boundary. +/// +internal sealed class LauncherPreferencesDocument +{ + public const int CurrentSchemaVersion = 1; + + public int SchemaVersion { get; set; } + + public LauncherInstallationsDocument? Installations { get; set; } + + public SupportedGame? LastSelectedGame { get; set; } + + public LauncherSharedPreferencesDocument? Shared { get; set; } + + public LauncherGamePreferencesSetDocument? Games { get; set; } +} + +/// +/// Defines the unversioned flat preferences format written before the standalone schema was introduced. +/// +internal sealed class LegacyLauncherPreferencesDocument +{ + public int? LaunchesCount { get; set; } + + public bool? AutoDeleteOldVersions { get; set; } + + public string? SelectedGameClient { get; set; } + + public bool HasKnownValues => + LaunchesCount.HasValue || + AutoDeleteOldVersions.HasValue || + SelectedGameClient is not null; +} + +internal sealed class LauncherInstallationsDocument +{ + public string? Generals { get; set; } + + public string? ZeroHour { get; set; } +} + +internal sealed class LauncherSharedPreferencesDocument +{ + public bool AutoDeleteOldVersions { get; set; } + + public bool HideLauncherAfterGameStart { get; set; } + + public bool EnableDiagnosticLogging { get; set; } + + public bool UseEnglishLanguage { get; set; } + + public bool HasShownRetailGenPatcherRecommendation { get; set; } +} + +internal sealed class LauncherGamePreferencesSetDocument +{ + public LauncherGamePreferencesDocument? Generals { get; set; } + + public LauncherGamePreferencesDocument? ZeroHour { get; set; } +} + +internal sealed class LauncherGamePreferencesDocument +{ + public int LaunchesCount { get; set; } + + public string? SelectedGameClient { get; set; } + + public string? SelectedWorldBuilder { get; set; } + + public string? GameArguments { get; set; } + + public string? WorldBuilderArguments { get; set; } + + public double ModsListVerticalOffset { get; set; } + + public int AdvertisingPositionInList { get; set; } + + public List? CustomGameClients { get; set; } + + public List? CustomWorldBuilders { get; set; } +} + +internal sealed class LauncherCustomExecutableDocument +{ + public string? DisplayName { get; set; } + + public string? ExecutableName { get; set; } +} diff --git a/GenLauncherGO.Infrastructure/Settings/Services/PreferencesService.cs b/GenLauncherGO.Infrastructure/Settings/Services/PreferencesService.cs new file mode 100644 index 00000000..13c5d97c --- /dev/null +++ b/GenLauncherGO.Infrastructure/Settings/Services/PreferencesService.cs @@ -0,0 +1,113 @@ +using System; +using GenLauncherGO.Core.Settings.Contracts; +using GenLauncherGO.Core.Settings.Exceptions; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Infrastructure.Persistence.Services; +using GenLauncherGO.Infrastructure.Settings.Models; +using GenLauncherGO.Infrastructure.Settings.Support; + +namespace GenLauncherGO.Infrastructure.Settings.Services; + +/// +/// Persists launcher preferences as a standalone YAML document. +/// +internal sealed class PreferencesService : ILauncherPreferencesService +{ + private readonly IYamlDocumentStore _documentStore; + + private readonly IYamlDocumentStore _legacyDocumentStore; + private readonly IYamlDocumentStore _schemaDocumentStore; + + public PreferencesService( + IYamlDocumentStore schemaDocumentStore, + IYamlDocumentStore documentStore, + IYamlDocumentStore legacyDocumentStore) + { + _schemaDocumentStore = schemaDocumentStore ?? throw new ArgumentNullException(nameof(schemaDocumentStore)); + _documentStore = documentStore ?? throw new ArgumentNullException(nameof(documentStore)); + _legacyDocumentStore = legacyDocumentStore ?? throw new ArgumentNullException(nameof(legacyDocumentStore)); + Current = LoadPreferences(); + } + + public event EventHandler? PreferencesChanged; + + public LauncherPreferences Current { get; private set; } + + public void Update(LauncherPreferences preferences) + { + ArgumentNullException.ThrowIfNull(preferences); + + LauncherPreferences normalizedPreferences = LauncherPreferencesDocumentMapper.Normalize(preferences); + if (normalizedPreferences == Current) + { + return; + } + + SavePreferences(normalizedPreferences); + + Current = normalizedPreferences; + PreferencesChanged?.Invoke(this, Current); + } + + private LauncherPreferences LoadPreferences() + { + if (!_schemaDocumentStore.DocumentExists) + { + return new LauncherPreferences(); + } + + LauncherPreferencesSchemaDocument schemaDocument = _schemaDocumentStore.Load( + new LauncherPreferencesSchemaDocument { SchemaVersion = null }); + if (!schemaDocument.HasSchemaVersion || schemaDocument.SchemaVersion == 0) + { + return LoadLegacyPreferences(); + } + + if (schemaDocument.SchemaVersion != LauncherPreferencesDocument.CurrentSchemaVersion) + { + return ResetPreferences(); + } + + LauncherPreferencesDocument document = _documentStore.Load(new LauncherPreferencesDocument()); + return document.SchemaVersion == LauncherPreferencesDocument.CurrentSchemaVersion + ? LauncherPreferencesDocumentMapper.ToPreferences(document) + : ResetPreferences(); + } + + private LauncherPreferences LoadLegacyPreferences() + { + LegacyLauncherPreferencesDocument legacyDocument = + _legacyDocumentStore.Load(new LegacyLauncherPreferencesDocument()); + if (!legacyDocument.HasKnownValues) + { + return ResetPreferences(); + } + + LauncherPreferences migratedPreferences = + LauncherPreferencesDocumentMapper.MigrateLegacyPreferences(legacyDocument); + return PersistLoadedPreferences(migratedPreferences); + } + + private LauncherPreferences ResetPreferences() + { + return PersistLoadedPreferences(new LauncherPreferences()); + } + + private LauncherPreferences PersistLoadedPreferences(LauncherPreferences preferences) + { + SavePreferences(preferences); + return preferences; + } + + private void SavePreferences(LauncherPreferences preferences) + { + try + { + _documentStore.Save(LauncherPreferencesDocumentMapper.ToDocument(preferences)); + } + catch (Exception exception) + { + throw new LauncherPreferencesPersistenceException(exception); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Settings/Support/LauncherPreferencesDocumentMapper.cs b/GenLauncherGO.Infrastructure/Settings/Support/LauncherPreferencesDocumentMapper.cs new file mode 100644 index 00000000..56e110f2 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Settings/Support/LauncherPreferencesDocumentMapper.cs @@ -0,0 +1,297 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Settings.Models; + +namespace GenLauncherGO.Infrastructure.Settings.Support; + +/// +/// Maps the standalone preferences persistence schema once into normalized Core preferences. +/// +internal static class LauncherPreferencesDocumentMapper +{ + public static LauncherPreferences ToPreferences(LauncherPreferencesDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + if (document.SchemaVersion != LauncherPreferencesDocument.CurrentSchemaVersion) + { + throw new NotSupportedException( + $"Launcher preferences schema version {document.SchemaVersion} is not supported."); + } + + LauncherInstallationsDocument installations = document.Installations ?? new LauncherInstallationsDocument(); + LauncherGamePreferencesSetDocument games = document.Games ?? new LauncherGamePreferencesSetDocument(); + + return Normalize(new LauncherPreferences + { + Installations = new LauncherInstallations + { + Generals = installations.Generals, + ZeroHour = installations.ZeroHour + }, + LastSelectedGame = document.LastSelectedGame, + Shared = MapShared(document.Shared), + Games = new LauncherGamePreferencesSet + { + Generals = MapGame(games.Generals), + ZeroHour = MapGame(games.ZeroHour) + } + }); + } + + /// + /// Migrates the unversioned flat preferences format into the current normalized model. + /// + public static LauncherPreferences MigrateLegacyPreferences(LegacyLauncherPreferencesDocument document) + { + ArgumentNullException.ThrowIfNull(document); + + return Normalize(new LauncherPreferences + { + Shared = new LauncherSharedPreferences + { + AutoDeleteOldVersions = document.AutoDeleteOldVersions ?? false + }, + Games = new LauncherGamePreferencesSet + { + ZeroHour = new LauncherGamePreferences + { + LaunchesCount = document.LaunchesCount ?? 0, + SelectedGameClient = document.SelectedGameClient ?? string.Empty + } + } + }); + } + + public static LauncherPreferencesDocument ToDocument(LauncherPreferences preferences) + { + LauncherPreferences normalized = Normalize(preferences); + + return new LauncherPreferencesDocument + { + SchemaVersion = LauncherPreferencesDocument.CurrentSchemaVersion, + Installations = new LauncherInstallationsDocument + { + Generals = normalized.Installations.Generals, + ZeroHour = normalized.Installations.ZeroHour + }, + LastSelectedGame = normalized.LastSelectedGame, + Shared = new LauncherSharedPreferencesDocument + { + AutoDeleteOldVersions = normalized.Shared.AutoDeleteOldVersions, + HideLauncherAfterGameStart = normalized.Shared.HideLauncherAfterGameStart, + EnableDiagnosticLogging = normalized.Shared.EnableDiagnosticLogging, + UseEnglishLanguage = normalized.Shared.UseEnglishLanguage, + HasShownRetailGenPatcherRecommendation = + normalized.Shared.HasShownRetailGenPatcherRecommendation + }, + Games = new LauncherGamePreferencesSetDocument + { + Generals = MapGame(normalized.Games.Generals), + ZeroHour = MapGame(normalized.Games.ZeroHour) + } + }; + } + + public static LauncherPreferences Normalize(LauncherPreferences preferences) + { + ArgumentNullException.ThrowIfNull(preferences); + + LauncherInstallations installations = preferences.Installations ?? new LauncherInstallations(); + LauncherSharedPreferences shared = preferences.Shared ?? new LauncherSharedPreferences(); + LauncherGamePreferencesSet games = preferences.Games ?? new LauncherGamePreferencesSet(); + + return new LauncherPreferences + { + Installations = new LauncherInstallations + { + Generals = NormalizePath(installations.Generals), + ZeroHour = NormalizePath(installations.ZeroHour) + }, + LastSelectedGame = NormalizeGame(preferences.LastSelectedGame), + Shared = shared, + Games = new LauncherGamePreferencesSet + { + Generals = NormalizeGamePreferences(games.Generals, SupportedGame.Generals), + ZeroHour = NormalizeGamePreferences(games.ZeroHour, SupportedGame.ZeroHour) + } + }; + } + + private static LauncherSharedPreferences MapShared(LauncherSharedPreferencesDocument? shared) + { + return shared is null + ? new LauncherSharedPreferences() + : new LauncherSharedPreferences + { + AutoDeleteOldVersions = shared.AutoDeleteOldVersions, + HideLauncherAfterGameStart = shared.HideLauncherAfterGameStart, + EnableDiagnosticLogging = shared.EnableDiagnosticLogging, + UseEnglishLanguage = shared.UseEnglishLanguage, + HasShownRetailGenPatcherRecommendation = shared.HasShownRetailGenPatcherRecommendation + }; + } + + private static LauncherGamePreferences MapGame(LauncherGamePreferencesDocument? game) + { + return game is null + ? new LauncherGamePreferences() + : new LauncherGamePreferences + { + LaunchesCount = game.LaunchesCount, + SelectedGameClient = game.SelectedGameClient ?? string.Empty, + SelectedWorldBuilder = game.SelectedWorldBuilder ?? string.Empty, + GameArguments = game.GameArguments ?? string.Empty, + WorldBuilderArguments = game.WorldBuilderArguments ?? string.Empty, + ModsListVerticalOffset = game.ModsListVerticalOffset, + AdvertisingPositionInList = game.AdvertisingPositionInList, + CustomGameClients = MapCustomExecutables(game.CustomGameClients), + CustomWorldBuilders = MapCustomExecutables(game.CustomWorldBuilders) + }; + } + + private static LauncherGamePreferencesDocument MapGame(LauncherGamePreferences game) + { + return new LauncherGamePreferencesDocument + { + LaunchesCount = game.LaunchesCount, + SelectedGameClient = game.SelectedGameClient, + SelectedWorldBuilder = game.SelectedWorldBuilder, + GameArguments = game.GameArguments, + WorldBuilderArguments = game.WorldBuilderArguments, + ModsListVerticalOffset = game.ModsListVerticalOffset, + AdvertisingPositionInList = game.AdvertisingPositionInList, + CustomGameClients = MapCustomExecutables(game.CustomGameClients), + CustomWorldBuilders = MapCustomExecutables(game.CustomWorldBuilders) + }; + } + + private static LauncherGamePreferences NormalizeGamePreferences( + LauncherGamePreferences? preferences, + SupportedGame game) + { + if (preferences is null) + { + return new LauncherGamePreferences(); + } + + return preferences with + { + LaunchesCount = Math.Max(0, preferences.LaunchesCount), + SelectedGameClient = (preferences.SelectedGameClient ?? string.Empty).Trim(), + SelectedWorldBuilder = (preferences.SelectedWorldBuilder ?? string.Empty).Trim(), + GameArguments = preferences.GameArguments ?? string.Empty, + WorldBuilderArguments = preferences.WorldBuilderArguments ?? string.Empty, + ModsListVerticalOffset = double.IsFinite(preferences.ModsListVerticalOffset) + ? Math.Max(0, preferences.ModsListVerticalOffset) + : 0, + AdvertisingPositionInList = Math.Max(0, preferences.AdvertisingPositionInList), + CustomGameClients = NormalizeCustomExecutables( + preferences.CustomGameClients, + LauncherFileSystemLayout.GetBuiltInGameExecutableNames(game)), + CustomWorldBuilders = NormalizeCustomExecutables( + preferences.CustomWorldBuilders, + LauncherFileSystemLayout.GetBuiltInWorldBuilderExecutableNames(game)) + }; + } + + private static IReadOnlyList MapCustomExecutables( + IReadOnlyList? documents) + { + if (documents == null || documents.Count == 0) + { + return Array.Empty(); + } + + var executables = new List(documents.Count); + foreach (LauncherCustomExecutableDocument document in documents) + { + if (document == null) + { + continue; + } + + try + { + executables.Add(new LauncherCustomExecutable( + document.DisplayName ?? string.Empty, + document.ExecutableName ?? string.Empty)); + } + catch (ArgumentException) + { + // Invalid persisted custom entries are ignored at the settings boundary. + } + } + + return executables; + } + + private static List MapCustomExecutables( + IReadOnlyList executables) + { + return executables + .Select(executable => new LauncherCustomExecutableDocument + { + DisplayName = executable.DisplayName, + ExecutableName = executable.ExecutableName + }) + .ToList(); + } + + private static IReadOnlyList NormalizeCustomExecutables( + IReadOnlyList? executables, + IEnumerable builtInNames) + { + if (executables == null || executables.Count == 0) + { + return Array.Empty(); + } + + var displayNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var executableNames = new HashSet(builtInNames, StringComparer.OrdinalIgnoreCase); + var normalized = new List(executables.Count); + bool changed = false; + + foreach (LauncherCustomExecutable? executable in executables) + { + if (executable == null || + !displayNames.Add(executable.DisplayName) || + !executableNames.Add(executable.ExecutableName)) + { + changed = true; + continue; + } + + normalized.Add(executable); + } + + return changed ? normalized : executables; + } + + private static SupportedGame? NormalizeGame(SupportedGame? game) + { + return game is SupportedGame.Generals or SupportedGame.ZeroHour ? game : null; + } + + private static string? NormalizePath(string? path) + { + if (string.IsNullOrWhiteSpace(path) || !Path.IsPathFullyQualified(path.Trim())) + { + return null; + } + + try + { + return LexicalPath.NormalizeFullPath(path.Trim()); + } + catch (Exception exception) when (exception is ArgumentException or IOException or NotSupportedException) + { + return null; + } + } +} diff --git a/GenLauncherGO.Infrastructure/Shell/Services/WindowsLauncherShellService.cs b/GenLauncherGO.Infrastructure/Shell/Services/WindowsLauncherShellService.cs new file mode 100644 index 00000000..6ca4ba52 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Shell/Services/WindowsLauncherShellService.cs @@ -0,0 +1,159 @@ +using System; +using System.ComponentModel; +using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Security; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Shell.Contracts; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Shell.Services; + +/// +/// Opens external targets through the Windows shell. +/// +internal sealed class WindowsLauncherShellService : ILauncherShellService +{ + private readonly ILogger _logger; + + private readonly Action _openShellTarget; + + public WindowsLauncherShellService(ILogger? logger = null) + : this(logger, OpenShellTarget) + { + } + + internal WindowsLauncherShellService( + ILogger? logger, + Action openShellTarget) + { + _logger = logger ?? NullLogger.Instance; + _openShellTarget = openShellTarget ?? throw new ArgumentNullException(nameof(openShellTarget)); + } + + public void OpenUri(string uri) + { + if (string.IsNullOrWhiteSpace(uri)) + { + _logger.LogWarning("Could not open shell URI because the target is empty."); + return; + } + + if (!Uri.TryCreate(uri, UriKind.Absolute, out Uri? parsedUri)) + { + _logger.LogWarning("Could not open shell URI because the target is not absolute."); + return; + } + + if (!string.Equals(parsedUri.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) + && !string.Equals(parsedUri.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning( + "Could not open shell URI because scheme {Scheme} is unsupported.", + parsedUri.Scheme); + return; + } + + OpenShellTarget(parsedUri.AbsoluteUri, GetUriLogTarget(parsedUri)); + } + + public void OpenFolder( + string folderPath, + bool requireFiles = false, + bool createIfMissing = false) + { + if (string.IsNullOrWhiteSpace(folderPath)) + { + _logger.LogWarning("Could not open shell folder because the target is empty."); + return; + } + + string fullPath; + try + { + fullPath = LexicalPath.NormalizeFullPath(folderPath); + } + catch (Exception exception) when (exception is ArgumentException or NotSupportedException + or PathTooLongException) + { + _logger.LogWarning(exception, "Could not normalize the shell folder target."); + return; + } + + if (!Directory.Exists(fullPath) && createIfMissing) + { + try + { + Directory.CreateDirectory(fullPath); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException + or SecurityException) + { + _logger.LogWarning( + exception, + "Could not create shell folder target {Target}.", + GetFolderLogTarget(fullPath)); + + return; + } + } + + if (!Directory.Exists(fullPath)) + { + _logger.LogWarning( + "Could not open shell folder {Target} because it does not exist.", + GetFolderLogTarget(fullPath)); + return; + } + + if (requireFiles && !Directory.EnumerateFiles(fullPath).Any()) + { + _logger.LogWarning( + "Could not open shell folder {Target} because it does not contain files.", + GetFolderLogTarget(fullPath)); + return; + } + + OpenShellTarget(fullPath, GetFolderLogTarget(fullPath)); + } + + private void OpenShellTarget(string target, string logTarget) + { + try + { + _openShellTarget(target); + } + catch (Exception exception) when (exception is Win32Exception or InvalidOperationException or IOException) + { + _logger.LogWarning( + exception, + "Could not open shell target {Target}.", + logTarget); + } + } + + private static string GetUriLogTarget(Uri uri) + { + return string.IsNullOrWhiteSpace(uri.Host) + ? uri.Scheme + : uri.Host; + } + + private static string GetFolderLogTarget(string fullPath) + { + string folderName = Path.GetFileName(Path.TrimEndingDirectorySeparator(fullPath)); + return string.IsNullOrWhiteSpace(folderName) + ? "folder" + : folderName; + } + + [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..51ad66c4 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Startup/FileSystemLauncherPathResolver.cs @@ -0,0 +1,75 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Startup; + +/// +/// Resolves and safely prepares standalone launcher-owned paths. +/// +public sealed class FileSystemLauncherPathResolver : ILauncherPathResolver +{ + private readonly ILogger _logger; + + public FileSystemLauncherPathResolver() + : this(NullLogger.Instance) + { + } + + public FileSystemLauncherPathResolver(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public LauncherStoragePaths Resolve(string executableDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executableDirectory); + + return new LauncherStoragePaths(executableDirectory); + } + + public void PrepareLauncherDirectories(LauncherStoragePaths paths) + { + ArgumentNullException.ThrowIfNull(paths); + + OwnedDirectoryTree.EnsureExists(paths.ExecutableDirectory, paths.DataDirectory); + OwnedDirectoryTree.EnsureExists(paths.DataDirectory, paths.LogsDirectory); + + _logger.LogDebug("Prepared shared standalone launcher directories."); + } + + public void PrepareGameDirectories(LauncherPaths paths, bool cleanTemporaryDirectory) + { + ArgumentNullException.ThrowIfNull(paths); + + string dataDirectory = Path.GetDirectoryName(paths.OwnedGameDataDirectory) + ?? throw new InvalidDataException( + "A per-game data directory must have an owning shared data directory."); + OwnedDirectoryTree.EnsureExists(dataDirectory, paths.OwnedGameDataDirectory); + OwnedDirectoryTree.EnsureExists(paths.OwnedGameDataDirectory, paths.RuntimeDirectory); + OwnedDirectoryTree.EnsureExists(paths.OwnedGameDataDirectory, paths.CacheDirectory); + OwnedDirectoryTree.EnsureExists(paths.OwnedGameDataDirectory, paths.ImagesDirectory); + OwnedDirectoryTree.EnsureExists(paths.OwnedGameDataDirectory, paths.ModsDirectory); + OwnedDirectoryTree.EnsureExists(paths.OwnedGameDataDirectory, paths.TempDirectory); + OwnedDirectoryTree.EnsureExists(paths.OwnedGameDataDirectory, paths.DeploymentDirectory); + OwnedDirectoryTree.EnsureExists(paths.OwnedGameDataDirectory, paths.IntegrityDirectory); + OwnedDirectoryTree.EnsureExists(paths.OwnedGameDataDirectory, paths.StateDirectory); + + if (cleanTemporaryDirectory) + { + // Staged packages are kept: a download the launcher suspended on close leaves its partial content + // here, and that content is exactly what lets the next session resume instead of starting over. + OwnedDirectoryTree.PrepareEmptyExcept( + paths.OwnedGameDataDirectory, + paths.TempDirectory, + paths.PackagesDirectory); + } + + _logger.LogDebug( + "Prepared isolated launcher directories for {SupportedGame}.", + paths.Game); + } +} diff --git a/GenLauncherGO.Infrastructure/Startup/IGameInstallationRegistry.cs b/GenLauncherGO.Infrastructure/Startup/IGameInstallationRegistry.cs new file mode 100644 index 00000000..c4caccf2 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Startup/IGameInstallationRegistry.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Infrastructure.Startup; + +/// +/// Supplies untrusted Windows registry candidates in installation-source priority order. +/// +internal interface IGameInstallationRegistry +{ + IReadOnlyList ReadCandidates(SupportedGame game); +} diff --git a/GenLauncherGO.Infrastructure/Startup/WindowsGameInstallationRegistry.cs b/GenLauncherGO.Infrastructure/Startup/WindowsGameInstallationRegistry.cs new file mode 100644 index 00000000..47414d45 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Startup/WindowsGameInstallationRegistry.cs @@ -0,0 +1,126 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Startup; +using Microsoft.Win32; + +namespace GenLauncherGO.Infrastructure.Startup; + +/// +/// Reads the GeneralsGameCode installation registry contract in storefront priority order. +/// +internal sealed class WindowsGameInstallationRegistry : IGameInstallationRegistry +{ + private const string GeneralsKey = + @"SOFTWARE\Electronic Arts\EA Games\Generals"; + + private const string ZeroHourEaKey = + @"SOFTWARE\Electronic Arts\EA Games\Command and Conquer Generals Zero Hour"; + + private const string ZeroHourSteamKey = + @"SOFTWARE\Electronic Arts\EA Games\ZeroHour"; + + private const string FirstDecadeKey = + @"SOFTWARE\Electronic Arts\EA Games\Command and Conquer The First Decade"; + + private static readonly (string KeyName, string ValueName)[] _generalsProbes = + [ + // Windows value names are case-insensitive, but GeneralsGameCode declares these + // separately for the EA App and Steam. Preserve that external contract and order. + (GeneralsKey, "InstallPath"), + (FirstDecadeKey, "gr_folder"), + (GeneralsKey, "installPath") + ]; + + private static readonly (string KeyName, string ValueName)[] _zeroHourProbes = + [ + (ZeroHourEaKey, "InstallPath"), + (FirstDecadeKey, "zh_folder"), + (ZeroHourSteamKey, "installPath") + ]; + + private static readonly RegistryView[] _views = + [ + RegistryView.Registry32, + RegistryView.Registry64 + ]; + + private readonly Func _readValue; + + internal WindowsGameInstallationRegistry() + : this(ReadLocalMachineValue) + { + } + + internal WindowsGameInstallationRegistry( + Func readValue) + { + _readValue = readValue ?? throw new ArgumentNullException(nameof(readValue)); + } + + public IReadOnlyList ReadCandidates(SupportedGame game) + { + IReadOnlyList<(string KeyName, string ValueName)> probes = game switch + { + SupportedGame.Generals => _generalsProbes, + SupportedGame.ZeroHour => _zeroHourProbes, + _ => throw PerGame.Unsupported(game, nameof(game)) + }; + + var candidates = new List(); + foreach ((string keyName, string valueName) in probes) + { + foreach (RegistryView view in _views) + { + AddCandidate(_readValue(view, keyName, valueName), candidates); + } + } + + return candidates; + } + + private static string? ReadLocalMachineValue( + RegistryView view, + string keyName, + string valueName) + { + try + { + using var baseKey = RegistryKey.OpenBaseKey(RegistryHive.LocalMachine, view); + using RegistryKey? key = baseKey.OpenSubKey(keyName, false); + return key?.GetValue(valueName, null, RegistryValueOptions.DoNotExpandEnvironmentNames) + as string; + } + catch (Exception exception) when ( + exception is IOException or SecurityException or UnauthorizedAccessException + or PlatformNotSupportedException) + { + // Registry candidates are optional. Every value that is read is validated against the filesystem later. + return null; + } + } + + private static void AddCandidate(string? rawCandidate, List candidates) + { + if (rawCandidate is null) + { + return; + } + + string candidate = NormalizeRegistryValue(rawCandidate); + if (!string.IsNullOrWhiteSpace(candidate) && + !candidates.Exists(existing => + LexicalPath.AreEquivalent(existing, candidate))) + { + candidates.Add(candidate); + } + } + + private static string NormalizeRegistryValue(string value) + { + string candidate = Environment.ExpandEnvironmentVariables(value.Trim().Trim('"')); + return Path.TrimEndingDirectorySeparator(candidate); + } +} diff --git a/GenLauncherGO.Infrastructure/Startup/WindowsGameInstallationService.cs b/GenLauncherGO.Infrastructure/Startup/WindowsGameInstallationService.cs new file mode 100644 index 00000000..d77df4e8 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Startup/WindowsGameInstallationService.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Startup.Contracts; +using GenLauncherGO.Core.Startup.Models; +using GenLauncherGO.Infrastructure.Common; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Startup; + +/// +/// Validates selected game directories and discovers candidates from the Windows registry. +/// +public sealed class WindowsGameInstallationService : IGameInstallationService +{ + // Zero Hour wins when one physical directory satisfies both games, so discovery never assigns the same + // installation to both titles and containing-installation detection stays deterministic. + private static readonly SupportedGame[] _gamesInDetectionPriorityOrder = + [ + SupportedGame.ZeroHour, + SupportedGame.Generals + ]; + + private readonly ILogger _logger; + + private readonly IGameInstallationRegistry _registry; + + public WindowsGameInstallationService() + : this( + new WindowsGameInstallationRegistry(), + NullLogger.Instance) + { + } + + public WindowsGameInstallationService(ILogger logger) + : this(new WindowsGameInstallationRegistry(), logger) + { + } + + internal WindowsGameInstallationService( + IGameInstallationRegistry registry, + ILogger logger) + { + _registry = registry ?? throw new ArgumentNullException(nameof(registry)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public GameInstallationLocation? FindContainingInstallation(string executableDirectory) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executableDirectory); + + string canonicalExecutablePath = PhysicalDirectoryPath.ResolveExisting(executableDirectory); + for (DirectoryInfo? directory = new(canonicalExecutablePath); + directory is not null; + directory = directory.Parent) + { + foreach (SupportedGame game in _gamesInDetectionPriorityOrder) + { + if (HasRecognizedExecutable(game, directory.FullName)) + { + return new GameInstallationLocation(game, directory.FullName); + } + } + } + + return null; + } + + public GameInstallationValidationResult Validate( + SupportedGame game, + string? directory, + string executableDirectory) + { + PerGame.EnsureSupported(game, nameof(game)); + ArgumentException.ThrowIfNullOrWhiteSpace(executableDirectory); + + if (string.IsNullOrWhiteSpace(directory)) + { + return GameInstallationValidationResult.Invalid(GameInstallationValidationFailure.PathMissing); + } + + string candidatePath; + try + { + string selectedDirectory = directory.Trim().Trim('"').Trim(); + if (string.IsNullOrWhiteSpace(selectedDirectory)) + { + return GameInstallationValidationResult.Invalid(GameInstallationValidationFailure.PathMissing); + } + + if (!Path.IsPathFullyQualified(selectedDirectory)) + { + return GameInstallationValidationResult.Invalid(GameInstallationValidationFailure.PathUnavailable); + } + + candidatePath = LexicalPath.NormalizeFullPath(selectedDirectory); + } + catch (Exception exception) when (exception is ArgumentException or IOException or NotSupportedException) + { + return GameInstallationValidationResult.Invalid(GameInstallationValidationFailure.PathUnavailable); + } + + if (!Directory.Exists(candidatePath)) + { + return GameInstallationValidationResult.Invalid(GameInstallationValidationFailure.DirectoryNotFound); + } + + try + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + candidatePath, + "Game installation paths"); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + executableDirectory, + "Launcher paths"); + } + catch (InvalidDataException) + { + return GameInstallationValidationResult.Invalid(GameInstallationValidationFailure.UnsafeFileSystemPath); + } + catch (Exception exception) when ( + exception is ArgumentException or IOException or NotSupportedException or UnauthorizedAccessException) + { + _logger.LogWarning( + exception, + "A {SupportedGame} installation candidate path could not be safely inspected.", + game); + return GameInstallationValidationResult.Invalid(GameInstallationValidationFailure.PathUnavailable); + } + + try + { + string canonicalGamePath = PhysicalDirectoryPath.ResolveExisting(candidatePath); + string canonicalExecutablePath = PhysicalDirectoryPath.ResolveExisting(executableDirectory); + if (LexicalPath.IsPathInDirectory(canonicalExecutablePath, canonicalGamePath)) + { + return GameInstallationValidationResult.Invalid( + GameInstallationValidationFailure.LauncherLocationOverlapsGame); + } + + string sharedDataPath = Path.Combine( + canonicalExecutablePath, + LauncherFileSystemLayout.LauncherDataFolderName); + if (LexicalPath.IsPathInDirectory(canonicalGamePath, sharedDataPath)) + { + return GameInstallationValidationResult.Invalid(GameInstallationValidationFailure.UnsafeFileSystemPath); + } + + if (!HasBuiltInExecutable(game, canonicalGamePath)) + { + return GameInstallationValidationResult.Invalid( + GameInstallationValidationFailure.BuiltInExecutableNotFound); + } + + return GameInstallationValidationResult.Valid(canonicalGamePath); + } + catch (Exception exception) when ( + exception is ArgumentException or IOException or NotSupportedException or + UnauthorizedAccessException or Win32Exception) + { + _logger.LogWarning( + exception, + "A {SupportedGame} installation candidate could not be safely inspected.", + game); + return GameInstallationValidationResult.Invalid(GameInstallationValidationFailure.PathUnavailable); + } + } + + public LauncherInstallations DiscoverValidInstallations( + LauncherInstallations current, + string executableDirectory) + { + ArgumentNullException.ThrowIfNull(current); + ArgumentException.ThrowIfNullOrWhiteSpace(executableDirectory); + + LauncherInstallations discovered = current; + var occupiedPhysicalPaths = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (SupportedGame game in _gamesInDetectionPriorityOrder) + { + string? configuredPath = current.GetPath(game); + GameInstallationValidationResult configuredResult = + Validate(game, configuredPath, executableDirectory); + if (configuredResult.IsValid) + { + occupiedPhysicalPaths.Add(configuredResult.CanonicalPath!); + continue; + } + + foreach (string candidate in _registry.ReadCandidates(game)) + { + GameInstallationValidationResult candidateResult = + Validate(game, candidate, executableDirectory); + if (!candidateResult.IsValid) + { + continue; + } + + if (!occupiedPhysicalPaths.Add(candidateResult.CanonicalPath!)) + { + continue; + } + + discovered = discovered.WithPath(game, candidateResult.CanonicalPath); + _logger.LogInformation("Discovered a valid {SupportedGame} installation.", game); + break; + } + } + + return discovered; + } + + private static bool HasBuiltInExecutable(SupportedGame game, string directory) + { + foreach (string executableName in LauncherFileSystemLayout.GetBuiltInGameExecutableNames(game)) + { + string executablePath = Path.Combine(directory, executableName); + if (File.Exists(executablePath) && !FileSystemPathSafety.IsReparsePoint(executablePath)) + { + return true; + } + } + + return false; + } + + private static bool HasRecognizedExecutable(SupportedGame game, string directory) + { + return game switch + { + SupportedGame.Generals => + File.Exists(Path.Combine( + directory, + LauncherFileSystemLayout.GeneralsCommunityExecutableFileName)), + SupportedGame.ZeroHour => + File.Exists(Path.Combine( + directory, + LauncherFileSystemLayout.ZeroHourCommunityExecutableFileName)) || + File.Exists(Path.Combine( + directory, + LauncherFileSystemLayout.GeneralsOnlineExecutableFileName)), + _ => false + }; + } +} diff --git a/GenLauncherGO.Infrastructure/Startup/WindowsLauncherHostEnvironmentService.cs b/GenLauncherGO.Infrastructure/Startup/WindowsLauncherHostEnvironmentService.cs new file mode 100644 index 00000000..f63dd045 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Startup/WindowsLauncherHostEnvironmentService.cs @@ -0,0 +1,186 @@ +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.IO; +using GenLauncherGO.Core.Startup.Contracts; +using GenLauncherGO.Core.Startup.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Startup; + +/// +/// Provides Windows process, elevation, single-instance, and foreground-window startup operations. +/// +public sealed class WindowsLauncherHostEnvironmentService : ILauncherHostEnvironmentService +{ + private const int SwRestore = 9; + + private readonly ILogger _logger; + private readonly Action _waitBeforeSingleInstanceRetry; + + public WindowsLauncherHostEnvironmentService() + : this(NullLogger.Instance) + { + } + + public WindowsLauncherHostEnvironmentService(ILogger logger) + : this(logger, Thread.Sleep) + { + } + + internal WindowsLauncherHostEnvironmentService( + ILogger logger, + Action waitBeforeSingleInstanceRetry) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _waitBeforeSingleInstanceRetry = waitBeforeSingleInstanceRetry ?? + throw new ArgumentNullException(nameof(waitBeforeSingleInstanceRetry)); + } + + 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 LauncherRestartResult TryRestartCurrentProcess() + { + try + { + string? executablePath = Environment.ProcessPath ?? Process.GetCurrentProcess().MainModule?.FileName; + if (string.IsNullOrWhiteSpace(executablePath)) + { + const string MissingExecutableMessage = "The launcher executable path could not be resolved."; + _logger.LogError(MissingExecutableMessage); + return LauncherRestartResult.Failure(MissingExecutableMessage); + } + + var process = Process.Start(new ProcessStartInfo + { + FileName = executablePath, + WorkingDirectory = Path.GetDirectoryName(executablePath) ?? AppContext.BaseDirectory, + UseShellExecute = true + }); + if (process == null) + { + const string StartFailureMessage = "Windows did not start the replacement launcher process."; + _logger.LogError(StartFailureMessage); + return LauncherRestartResult.Failure(StartFailureMessage); + } + + process.Dispose(); + _logger.LogInformation("Started a replacement launcher process for restart."); + return LauncherRestartResult.Success; + } + catch (Exception exception) + { + _logger.LogError(exception, "Could not start a replacement launcher process."); + return LauncherRestartResult.Failure(exception.Message); + } + } + + public ILauncherSingleInstanceGuard TryAcquireSingleInstance(string instanceName, TimeSpan retryDelay) + { + ArgumentException.ThrowIfNullOrWhiteSpace(instanceName); + ArgumentOutOfRangeException.ThrowIfLessThan(retryDelay, TimeSpan.Zero); + + Mutex mutex = new(true, instanceName, out bool createdNew); + if (createdNew) + { + return new MutexSingleInstanceGuard(mutex, true); + } + + mutex.Dispose(); + if (retryDelay > TimeSpan.Zero) + { + _waitBeforeSingleInstanceRetry(retryDelay); + } + + mutex = new Mutex(true, instanceName, out createdNew); + if (createdNew) + { + return new MutexSingleInstanceGuard(mutex, true); + } + + mutex.Dispose(); + return MutexSingleInstanceGuard.NotAcquired; + } + + private static bool IsPathInDirectoryWhenKnown(string path, string directory) + { + return !string.IsNullOrWhiteSpace(directory) && + LexicalPath.IsPathInDirectory(path, directory); + } + + [DllImport("user32.dll")] + private static extern bool SetForegroundWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + private static extern bool ShowWindowAsync(HandleRef hWnd, int nCmdShow); + + private sealed class MutexSingleInstanceGuard : ILauncherSingleInstanceGuard + { + public static readonly MutexSingleInstanceGuard NotAcquired = new(null, false); + + private readonly Mutex? _mutex; + + 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..6d27ffc3 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Clients/HttpDownloadFileMetadataReader.cs @@ -0,0 +1,80 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Infrastructure.Remote; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Clients; + +/// +/// Reads downloadable file metadata over HTTP. +/// +internal sealed class HttpDownloadFileMetadataReader : IDownloadFileMetadataReader +{ + private static readonly HttpClient _sharedHttpClient = + SharedHttpClientFactory.Create(TimeSpan.FromSeconds(60)); + + private readonly HttpClient _httpClient; + + public HttpDownloadFileMetadataReader(HttpClient? httpClient = null) + { + _httpClient = httpClient ?? _sharedHttpClient; + } + + public async Task ReadMetadataAsync( + Uri downloadUri, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(downloadUri); + + DownloadFileMetadata? metadata = await HttpHeadFallbackRequest.SendAsync( + _httpClient, + downloadUri, + response => ReadMetadata(downloadUri, response), + static result => result is null, + cancellationToken).ConfigureAwait(false); + + return metadata ?? throw new InvalidOperationException( + "Download link is incorrect, please contact modification creator and try again later."); + } + + private static DownloadFileMetadata? ReadMetadata( + Uri downloadUri, + HttpResponseMessage response) + { + 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, + NormalizeFileName(fileName), + response.Content.Headers.ContentLength); + } + + private static string NormalizeFileName(string fileName) + { + try + { + return LexicalPath.NormalizePathSegment(fileName.Trim('"'), nameof(fileName)); + } + catch (ArgumentException exception) + { + throw new InvalidOperationException( + "Download link is incorrect, please contact modification creator and try again later.", + exception); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Clients/MinioClientFactory.cs b/GenLauncherGO.Infrastructure/Updating/Clients/MinioClientFactory.cs new file mode 100644 index 00000000..430b6538 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Clients/MinioClientFactory.cs @@ -0,0 +1,46 @@ +using System; +using Minio; + +namespace GenLauncherGO.Infrastructure.Updating.Clients; + +internal static class MinioClientFactory +{ + /// + /// Creates an authenticated MinIO client; explicit endpoint URI schemes override the host-only SSL preference. + /// + 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..07615e7a --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Clients/MinioS3ObjectManifestReader.cs @@ -0,0 +1,118 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using Microsoft.Extensions.Logging; +using Minio; +using Minio.DataModel; +using Minio.DataModel.Args; + +namespace GenLauncherGO.Infrastructure.Updating.Clients; + +/// +/// Reads S3-compatible object listings with MinIO. +/// +internal sealed class MinioS3ObjectManifestReader : IS3ObjectManifestReader +{ + private readonly Func> + _listObjects; + + private readonly ILogger _logger; + + public MinioS3ObjectManifestReader(ILogger logger) + : this(logger, ListObjectsAsync) + { + } + + 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. + /// + 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); + + List files = []; + 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.LogDebug( + "Read {FileCount} S3 manifest entries from bucket {BucketName}, prefix {Prefix}.", + files.Count, + request.BucketName, + request.Prefix); + return files; + } + + [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) + { + using 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 string StripPrefix(string key, string prefix) + { + string normalizedPrefix = prefix.TrimEnd('/') + "/"; + if (key.StartsWith(normalizedPrefix, StringComparison.Ordinal)) + { + return key[normalizedPrefix.Length..]; + } + + return key; + } + + private static string NormalizeETag(string eTag) + { + return eTag.Trim().Trim('"'); + } + + 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..86910ae3 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Clients/ResumableHttpFileDownloader.cs @@ -0,0 +1,393 @@ +using System; +using System.Buffers; +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 GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Remote; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Updating.Clients; + +/// +/// Downloads files over HTTP using range requests, pooled buffers, retry backoff, and idle-transfer detection. +/// +internal sealed class ResumableHttpFileDownloader : IResumableFileDownloader +{ + private const int DefaultBufferSize = 1024 * 1024; + private const int DefaultMaxAttempts = 5; + + private static readonly HttpClient _sharedHttpClient = + SharedHttpClientFactory.Create(Timeout.InfiniteTimeSpan); + + private readonly int _bufferSize; + + private readonly HttpClient _httpClient; + private readonly TimeSpan _idleTimeout; + private readonly TimeSpan _initialRetryDelay; + private readonly ILogger _logger; + private readonly int _maxAttempts; + private readonly TimeSpan _progressReportInterval; + private readonly TimeProvider _timeProvider; + + public ResumableHttpFileDownloader( + HttpClient? httpClient = null, + ILogger? logger = null) + : this( + httpClient, + logger, + DefaultBufferSize, + DefaultMaxAttempts, + TimeSpan.FromSeconds(30), + TimeSpan.FromMilliseconds(100), + TimeSpan.FromSeconds(1)) + { + } + + internal ResumableHttpFileDownloader( + HttpClient? httpClient, + ILogger? logger, + int bufferSize, + int maxAttempts, + TimeSpan idleTimeout, + TimeSpan progressReportInterval, + TimeSpan initialRetryDelay, + TimeProvider? timeProvider = null) + { + _httpClient = httpClient ?? _sharedHttpClient; + _logger = logger ?? NullLogger.Instance; + _bufferSize = bufferSize; + _maxAttempts = maxAttempts; + _idleTimeout = idleTimeout; + _progressReportInterval = progressReportInterval; + _initialRetryDelay = initialRetryDelay; + _timeProvider = timeProvider ?? TimeProvider.System; + + if (_bufferSize <= 0) + { + throw new ArgumentOutOfRangeException(nameof(bufferSize), + "Download buffer size must be greater than zero."); + } + + if (_maxAttempts <= 0) + { + throw new ArgumentOutOfRangeException(nameof(maxAttempts), "Maximum attempts must be greater than zero."); + } + + if (_idleTimeout <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(idleTimeout), "Idle timeout must be greater than zero."); + } + + if (_progressReportInterval <= TimeSpan.Zero) + { + throw new ArgumentOutOfRangeException(nameof(progressReportInterval), + "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 = LexicalPath.NormalizeFullPath(request.DestinationFilePath); + Directory.CreateDirectory(Path.GetDirectoryName(destinationFilePath) ?? "."); + + Exception? lastException = null; + for (int attempt = 1; attempt <= _maxAttempts; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + await DownloadAttemptAsync( + request with { DestinationFilePath = destinationFilePath }, + progress, + cancellationToken).ConfigureAwait(false); + return; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) when (IsRetriable(exception) && attempt < _maxAttempts) + { + lastException = exception; + _logger.LogWarning( + "Download attempt {Attempt} failed for {FileName}; failure type: {FailureType}; retrying.", + attempt, + Path.GetFileName(destinationFilePath), + exception.GetType().Name); + + await Task.Delay(GetRetryDelay(attempt), cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (IsRetriable(exception)) + { + lastException = exception; + break; + } + } + + throw new IOException( + string.Format( + CultureInfo.InvariantCulture, + "Download failed after {0} attempts.", + _maxAttempts), + lastException); + } + + private async Task DownloadAttemptAsync( + DownloadFileRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + long existingBytes = GetExistingBytes(request); + if (request.Resume && + request.ExpectedBytes.HasValue && + File.Exists(request.DestinationFilePath) && + new FileInfo(request.DestinationFilePath).Length == request.ExpectedBytes.Value) + { + ReportProgress(progress, request.ExpectedBytes, existingBytes); + return; + } + + 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); + + 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; + ReportProgress(progress, totalBytes, bytesDownloaded); + + await using FileStream destinationStream = new( + request.DestinationFilePath, + fileMode, + FileAccess.Write, + FileShare.Read, + _bufferSize, + FileOptions.Asynchronous | FileOptions.SequentialScan); + + await using Stream responseStream = await response.Content.ReadAsStreamAsync(cancellationToken) + .ConfigureAwait(false); + + bytesDownloaded = await CopyToFileAsync( + responseStream, + destinationStream, + totalBytes, + bytesDownloaded, + progress, + request.PauseController, + 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)); + } + } + + 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; + } + + 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; + } + + private static bool ResponseStartsAtExpectedByte( + HttpResponseMessage response, + long expectedStartByte) + { + return response.Content.Headers.ContentRange?.From == expectedStartByte; + } + + private async Task CopyToFileAsync( + Stream responseStream, + FileStream destinationStream, + long? totalBytes, + long bytesDownloaded, + IProgress? progress, + PackageDownloadPauseController? pauseController, + CancellationToken cancellationToken) + { + byte[] buffer = ArrayPool.Shared.Rent(_bufferSize); + long progressStartTimestamp = _timeProvider.GetTimestamp(); + TimeSpan lastReportElapsed = TimeSpan.Zero; + + try + { + using var idleCancellation = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken); + + while (true) + { + await PackageDownloadPauseController.WaitWhilePausedAsync(pauseController, cancellationToken) + .ConfigureAwait(false); + idleCancellation.CancelAfter(_idleTimeout); + + int bytesRead; + try + { + bytesRead = await responseStream + .ReadAsync(buffer.AsMemory(0, _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 PackageDownloadPauseController.WaitWhilePausedAsync(pauseController, cancellationToken) + .ConfigureAwait(false); + await destinationStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken) + .ConfigureAwait(false); + + bytesDownloaded += bytesRead; + TimeSpan elapsed = _timeProvider.GetElapsedTime(progressStartTimestamp); + if (elapsed - lastReportElapsed >= _progressReportInterval) + { + ReportProgress(progress, totalBytes, bytesDownloaded); + lastReportElapsed = elapsed; + } + } + + ReportProgress(progress, totalBytes, bytesDownloaded); + return bytesDownloaded; + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + private static bool IsRetriable(Exception exception) + { + return exception is HttpRequestException or IOException or TimeoutException or + TaskCanceledException; + } + + private TimeSpan GetRetryDelay(int attempt) + { + double multiplier = Math.Pow(2, Math.Max(0, attempt - 1)); + double delayMilliseconds = _initialRetryDelay.TotalMilliseconds * multiplier; + return TimeSpan.FromMilliseconds(Math.Min(delayMilliseconds, TimeSpan.FromSeconds(30).TotalMilliseconds)); + } + + 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/Contracts/IDownloadFileMetadataReader.cs b/GenLauncherGO.Infrastructure/Updating/Contracts/IDownloadFileMetadataReader.cs new file mode 100644 index 00000000..b50d66ea --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Contracts/IDownloadFileMetadataReader.cs @@ -0,0 +1,13 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Contracts; + +internal interface IDownloadFileMetadataReader +{ + Task ReadMetadataAsync( + Uri downloadUri, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Updating/Contracts/IFileHashService.cs b/GenLauncherGO.Infrastructure/Updating/Contracts/IFileHashService.cs new file mode 100644 index 00000000..4a35112b --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Contracts/IFileHashService.cs @@ -0,0 +1,12 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Infrastructure.Updating.Contracts; + +internal interface IFileHashService +{ + /// + /// Computes an uppercase hexadecimal MD5 hash for a local file. + /// + Task ComputeMd5HashAsync(string filePath, CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Updating/Contracts/IResumableFileDownloader.cs b/GenLauncherGO.Infrastructure/Updating/Contracts/IResumableFileDownloader.cs new file mode 100644 index 00000000..bf67c77e --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Contracts/IResumableFileDownloader.cs @@ -0,0 +1,14 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Contracts; + +internal interface IResumableFileDownloader +{ + Task DownloadFileAsync( + DownloadFileRequest request, + IProgress? progress, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Updating/Contracts/IS3ObjectManifestReader.cs b/GenLauncherGO.Infrastructure/Updating/Contracts/IS3ObjectManifestReader.cs new file mode 100644 index 00000000..6b6fd1fa --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Contracts/IS3ObjectManifestReader.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Contracts; + +internal interface IS3ObjectManifestReader +{ + 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..8e671e41 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Contracts/IS3PackageUpdater.cs @@ -0,0 +1,24 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Contracts; + +internal interface IS3PackageUpdater +{ + Task UpdateAsync( + S3PackageUpdateRequest request, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null); + + /// + /// Downloads and repairs selected package files directly inside an installed S3-backed package. + /// + Task RepairFilesAsync( + S3PackageFileRepairRequest request, + IProgress? progress, + CancellationToken cancellationToken); +} diff --git a/GenLauncherGO.Infrastructure/Updating/Contracts/ISingleFilePackageUpdater.cs b/GenLauncherGO.Infrastructure/Updating/Contracts/ISingleFilePackageUpdater.cs new file mode 100644 index 00000000..99cc3158 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Contracts/ISingleFilePackageUpdater.cs @@ -0,0 +1,17 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Contracts; + +internal interface ISingleFilePackageUpdater +{ + Task UpdateAsync( + DownloadFileMetadata metadata, + PackageUpdatePathSet paths, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null); +} diff --git a/GenLauncherGO.Infrastructure/Updating/Models/DownloadFileMetadata.cs b/GenLauncherGO.Infrastructure/Updating/Models/DownloadFileMetadata.cs new file mode 100644 index 00000000..9386e5f1 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Models/DownloadFileMetadata.cs @@ -0,0 +1,8 @@ +using System; + +namespace GenLauncherGO.Infrastructure.Updating.Models; + +internal sealed record DownloadFileMetadata( + Uri DownloadUri, + string FileName, + long? TotalBytes); diff --git a/GenLauncherGO.Infrastructure/Updating/Models/DownloadFileRequest.cs b/GenLauncherGO.Infrastructure/Updating/Models/DownloadFileRequest.cs new file mode 100644 index 00000000..60f887c5 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Models/DownloadFileRequest.cs @@ -0,0 +1,11 @@ +using System; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Models; + +internal sealed record DownloadFileRequest( + Uri SourceUri, + string DestinationFilePath, + long? ExpectedBytes = null, + bool Resume = true, + PackageDownloadPauseController? PauseController = null); diff --git a/GenLauncherGO.Infrastructure/Updating/Models/DownloadProgress.cs b/GenLauncherGO.Infrastructure/Updating/Models/DownloadProgress.cs new file mode 100644 index 00000000..74876987 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Models/DownloadProgress.cs @@ -0,0 +1,6 @@ +namespace GenLauncherGO.Infrastructure.Updating.Models; + +internal sealed record DownloadProgress( + long? TotalBytes, + long BytesDownloaded, + double? ProgressPercentage); diff --git a/GenLauncherGO.Infrastructure/Updating/Models/PackageUpdatePathSet.cs b/GenLauncherGO.Infrastructure/Updating/Models/PackageUpdatePathSet.cs new file mode 100644 index 00000000..6f036bd1 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Models/PackageUpdatePathSet.cs @@ -0,0 +1,49 @@ +using System; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Infrastructure.Updating.Models; + +/// +/// Describes the explicit ownership boundaries for package staging, installed content, and durable recovery. +/// +internal sealed record PackageUpdatePathSet +{ + public PackageUpdatePathSet( + OwnedContentPath temporaryPath, + OwnedContentPath installedPath, + OwnedContentPath backupPath, + OwnedContentPath? latestInstalledPath = null) + { + TemporaryPath = temporaryPath ?? throw new ArgumentNullException(nameof(temporaryPath)); + InstalledPath = installedPath ?? throw new ArgumentNullException(nameof(installedPath)); + BackupPath = backupPath ?? throw new ArgumentNullException(nameof(backupPath)); + LatestInstalledPath = latestInstalledPath; + } + + public OwnedContentPath TemporaryPath { get; } + + public OwnedContentPath InstalledPath { get; } + + public OwnedContentPath BackupPath { get; } + + public OwnedContentPath? LatestInstalledPath { get; } + + /// + /// Creates package paths from canonical launcher paths and a centrally resolved installed path. + /// + public static PackageUpdatePathSet Create( + LauncherPaths launcherPaths, + OwnedContentPath installedPath, + OwnedContentPath? latestInstalledPath = null) + { + ArgumentNullException.ThrowIfNull(launcherPaths); + ArgumentNullException.ThrowIfNull(installedPath); + + return new PackageUpdatePathSet( + launcherPaths.GetPackageTemporaryPath(installedPath), + installedPath, + launcherPaths.GetPackageBackupPath(installedPath), + latestInstalledPath); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Models/RemoteFileManifestEntry.cs b/GenLauncherGO.Infrastructure/Updating/Models/RemoteFileManifestEntry.cs new file mode 100644 index 00000000..b24ab211 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Models/RemoteFileManifestEntry.cs @@ -0,0 +1,6 @@ +namespace GenLauncherGO.Infrastructure.Updating.Models; + +internal sealed record RemoteFileManifestEntry( + string FileName, + string Hash, + ulong Size); diff --git a/GenLauncherGO.Infrastructure/Updating/Models/S3ObjectManifestRequest.cs b/GenLauncherGO.Infrastructure/Updating/Models/S3ObjectManifestRequest.cs new file mode 100644 index 00000000..a1ca59ed --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Models/S3ObjectManifestRequest.cs @@ -0,0 +1,16 @@ +namespace GenLauncherGO.Infrastructure.Updating.Models; + +/// +/// Describes an S3-compatible object listing request for a modification version. +/// +/// +/// UseSsl defaults to for compatibility with legacy catalog endpoints that expose +/// plain MinIO ports; an explicit endpoint URI scheme takes precedence. +/// +internal 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..8678286a --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Models/S3PackageFileRepairRequest.cs @@ -0,0 +1,13 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Models; + +/// +/// Describes selected S3-backed package files that should be repaired in place. +/// +internal sealed record S3PackageFileRepairRequest( + IReadOnlyList Files, + S3ObjectManifestRequest Source, + OwnedContentPath InstalledPath, + IReadOnlySet HashCheckedExtensions); diff --git a/GenLauncherGO.Infrastructure/Updating/Models/S3PackageUpdateRequest.cs b/GenLauncherGO.Infrastructure/Updating/Models/S3PackageUpdateRequest.cs new file mode 100644 index 00000000..83353028 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Models/S3PackageUpdateRequest.cs @@ -0,0 +1,12 @@ +using System.Collections.Generic; + +namespace GenLauncherGO.Infrastructure.Updating.Models; + +/// +/// Describes an S3-backed package update. +/// +internal sealed record S3PackageUpdateRequest( + IReadOnlyList Files, + S3ObjectManifestRequest Source, + PackageUpdatePathSet PathSet, + IReadOnlySet HashCheckedExtensions); diff --git a/GenLauncherGO.Infrastructure/Updating/Services/ManagedPackageSourceResolver.cs b/GenLauncherGO.Infrastructure/Updating/Services/ManagedPackageSourceResolver.cs new file mode 100644 index 00000000..09fc9abe --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/ManagedPackageSourceResolver.cs @@ -0,0 +1,201 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Updating.Services; + +/// +/// Resolves and caches the remote metadata shared by package sizing, installation, and repair. +/// +internal sealed class ManagedPackageSourceResolver : IRemotePackageSizeResolver +{ + private readonly Dictionary _sourceCache = []; + private readonly Lock _cacheSync = new(); + private readonly IDownloadFileMetadataReader _downloadFileMetadataReader; + private readonly ILogger _logger; + private readonly IS3ObjectManifestReader _s3ObjectManifestReader; + private readonly HashSet _unavailableSizes = []; + + public ManagedPackageSourceResolver( + IDownloadFileMetadataReader downloadFileMetadataReader, + IS3ObjectManifestReader s3ObjectManifestReader, + ILogger logger) + { + _downloadFileMetadataReader = downloadFileMetadataReader ?? + throw new ArgumentNullException(nameof(downloadFileMetadataReader)); + _s3ObjectManifestReader = s3ObjectManifestReader ?? + throw new ArgumentNullException(nameof(s3ObjectManifestReader)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task GetTotalBytesAsync( + LauncherContentVersion version, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(version); + + var cacheKey = CacheKey.Create(version); + lock (_cacheSync) + { + if (_sourceCache.TryGetValue(cacheKey, out PackageSource? cachedSource)) + { + return cachedSource.TotalBytes; + } + + if (_unavailableSizes.Contains(cacheKey)) + { + return null; + } + } + + long? totalBytes; + try + { + PackageSource? source = await ResolveAsync(version, cancellationToken).ConfigureAwait(false); + totalBytes = source?.TotalBytes; + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning( + "Failed to resolve package size for {ContentIdentity} from {SourceKind}; failure type: {FailureType}.", + version.ContentKey.ToStableString(), + version.EffectiveContentSourceKind, + exception.GetType().Name); + totalBytes = null; + } + + lock (_cacheSync) + { + if (!_sourceCache.ContainsKey(cacheKey)) + { + _unavailableSizes.Add(cacheKey); + } + } + + return totalBytes; + } + + /// + /// Resolves one managed package source while retaining successful provider metadata for later consumers. + /// + public async Task ResolveAsync( + LauncherContentVersion version, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(version); + cancellationToken.ThrowIfCancellationRequested(); + + var cacheKey = CacheKey.Create(version); + lock (_cacheSync) + { + if (_sourceCache.TryGetValue(cacheKey, out PackageSource? cachedSource)) + { + return cachedSource; + } + } + + PackageSource? source = await ResolveCoreAsync(version, cancellationToken).ConfigureAwait(false); + if (source is not null) + { + lock (_cacheSync) + { + _sourceCache[cacheKey] = source; + _unavailableSizes.Remove(cacheKey); + } + } + + return source; + } + + private async Task ResolveCoreAsync( + LauncherContentVersion version, + CancellationToken cancellationToken) + { + switch (version.EffectiveContentSourceKind) + { + case ContentSourceKind.ManagedS3: + S3ObjectManifestRequest request = S3CatalogDefaults.CreateManifestRequest(version); + IReadOnlyList files = + await _s3ObjectManifestReader.ReadManifestAsync(request, cancellationToken).ConfigureAwait(false); + return new PackageSource.S3(request, files); + + case ContentSourceKind.ManagedSingleFile: + Uri downloadUri = DownloadLinkResolver.ResolveDownloadUri(version.SimpleDownloadLink); + DownloadFileMetadata metadata = await _downloadFileMetadataReader.ReadMetadataAsync( + downloadUri, + cancellationToken).ConfigureAwait(false); + return new PackageSource.SingleFile(metadata); + + default: + _logger.LogDebug( + "Package metadata is unavailable for {ContentIdentity} with unsupported source {SourceKind}.", + version.ContentKey.ToStableString(), + version.EffectiveContentSourceKind); + return null; + } + } + + private static long? SumManifestBytes(IEnumerable files) + { + try + { + ulong totalSize = 0; + foreach (RemoteFileManifestEntry entry in files) + { + totalSize = checked(totalSize + entry.Size); + } + + return checked((long)totalSize); + } + catch (OverflowException) + { + return null; + } + } + + internal abstract record PackageSource(long? TotalBytes) + { + internal sealed record S3( + S3ObjectManifestRequest Request, + IReadOnlyList Files) : PackageSource(SumManifestBytes(Files)); + + internal sealed record SingleFile(DownloadFileMetadata Metadata) : + PackageSource(Metadata.TotalBytes is >= 0 ? Metadata.TotalBytes : null); + } + + private readonly record struct CacheKey( + LauncherContentKey ContentKey, + ContentSourceKind SourceKind, + string S3Host, + string S3Bucket, + string S3Folder, + string S3PublicKey, + string S3SecretKey, + string DirectDownloadLink) + { + public static CacheKey Create(LauncherContentVersion version) + { + return new CacheKey( + version.ContentKey, + version.EffectiveContentSourceKind, + version.S3HostLink, + version.S3BucketName, + version.S3FolderName, + version.S3HostPublicKey, + version.S3HostSecretKey, + version.SimpleDownloadLink); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Services/Md5FileHashService.cs b/GenLauncherGO.Infrastructure/Updating/Services/Md5FileHashService.cs new file mode 100644 index 00000000..21c76e1a --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/Md5FileHashService.cs @@ -0,0 +1,52 @@ +using System; +using System.Globalization; +using System.IO; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Infrastructure.Updating.Services; + +/// +/// Computes MD5 hashes for local files. +/// +internal sealed class Md5FileHashService : IFileHashService +{ + private readonly ILogger _logger; + + 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/PackageDownloadService.cs b/GenLauncherGO.Infrastructure/Updating/Services/PackageDownloadService.cs new file mode 100644 index 00000000..0794a809 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/PackageDownloadService.cs @@ -0,0 +1,259 @@ +using System; +using System.IO; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +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.Common; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; +using Microsoft.Extensions.Logging; +using Minio.Exceptions; + +namespace GenLauncherGO.Infrastructure.Updating.Services; + +/// +/// Downloads and installs modification packages while keeping provider selection inside Infrastructure. +/// +internal sealed class PackageDownloadService : IPackageDownloadService +{ + private readonly ILogger _logger; + private readonly ManagedPackageSourceResolver _packageSourceResolver; + private readonly LauncherRuntimePathContext _runtimePathContext; + private readonly IS3PackageUpdater _s3PackageUpdater; + + private readonly ISingleFilePackageUpdater _singleFilePackageUpdater; + + public PackageDownloadService( + ISingleFilePackageUpdater singleFilePackageUpdater, + IS3PackageUpdater s3PackageUpdater, + ManagedPackageSourceResolver packageSourceResolver, + LauncherRuntimePathContext runtimePathContext, + ILogger logger) + { + _singleFilePackageUpdater = singleFilePackageUpdater ?? + throw new ArgumentNullException(nameof(singleFilePackageUpdater)); + _s3PackageUpdater = s3PackageUpdater ?? throw new ArgumentNullException(nameof(s3PackageUpdater)); + _packageSourceResolver = packageSourceResolver ?? + throw new ArgumentNullException(nameof(packageSourceResolver)); + _runtimePathContext = runtimePathContext ?? throw new ArgumentNullException(nameof(runtimePathContext)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task DownloadAsync( + LauncherContent modification, + LauncherContentVersion version, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null) + { + ArgumentNullException.ThrowIfNull(modification); + ArgumentNullException.ThrowIfNull(version); + pauseController ??= new PackageDownloadPauseController(); + + IProgress? monotonicProgress = progress is null + ? null + : new MonotonicPackageProgress(progress); + + try + { + await pauseController.WaitWhilePausedAsync(cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + LauncherPaths paths = _runtimePathContext.ActivePaths; + ManagedPackageSourceResolver.PackageSource? source = await _packageSourceResolver.ResolveAsync( + version, + cancellationToken).ConfigureAwait(false); + + switch (source) + { + case ManagedPackageSourceResolver.PackageSource.S3 s3Source: + await DownloadS3PackageAsync( + modification, + version, + s3Source, + paths, + monotonicProgress, + pauseController, + cancellationToken).ConfigureAwait(false); + break; + + case ManagedPackageSourceResolver.PackageSource.SingleFile singleFileSource: + await DownloadSingleFilePackageAsync( + version, + singleFileSource.Metadata, + paths, + monotonicProgress, + pauseController, + cancellationToken).ConfigureAwait(false); + break; + + default: + throw new InvalidOperationException( + $"Content source '{version.EffectiveContentSourceKind}' is not managed by the package downloader."); + } + + _logger.LogInformation( + "Completed package download for {ContentName} {ContentVersion}.", + version.Name, + version.Version); + return PackageDownloadResult.Succeeded(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + _logger.LogInformation( + "Canceled package download for {ContentName} {ContentVersion}.", + version.Name, + version.Version); + return PackageDownloadResult.Canceled(); + } + catch (Exception exception) when (cancellationToken.IsCancellationRequested) + { + _logger.LogDebug( + exception, + "Package provider surfaced a failure after cancellation for {ContentName} {ContentVersion}; treating the pre-commit operation as canceled.", + version.Name, + version.Version); + return PackageDownloadResult.Canceled(); + } + catch (UnexpectedMinioException exception) + { + return CreateRecoverableProviderFailure(version, exception); + } + catch (Exception exception) when (exception is HttpRequestException or TimeoutException) + { + return CreateRecoverableProviderFailure(version, exception); + } + catch (InvalidDataException exception) + { + _logger.LogWarning( + exception, + "Package validation failed for {ContentName} {ContentVersion}.", + version.Name, + version.Version); + return PackageDownloadResult.RecoverableFailure( + "The downloaded package could not be validated."); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + _logger.LogWarning( + exception, + "Package staging or installation failed for {ContentName} {ContentVersion}.", + version.Name, + version.Version); + return PackageDownloadResult.RecoverableFailure( + "The package could not be staged or installed in launcher storage."); + } + catch (Exception exception) + { + _logger.LogError( + exception, + "Package download failed unexpectedly for {ContentName} {ContentVersion}.", + version.Name, + version.Version); + return PackageDownloadResult.UnexpectedFailure( + "An unexpected package download error occurred."); + } + } + + private async Task DownloadSingleFilePackageAsync( + LauncherContentVersion version, + DownloadFileMetadata metadata, + LauncherPaths paths, + IProgress? progress, + PackageDownloadPauseController pauseController, + CancellationToken cancellationToken) + { + PackageUpdatePathSet packagePaths = CreatePackagePaths(paths, version); + OwnedDirectoryTree.EnsureExists( + packagePaths.TemporaryPath.OwnerRoot, + packagePaths.TemporaryPath.FullPath); + + _logger.LogInformation( + "Starting single-file package download for {ContentName} {ContentVersion}.", + version.Name, + version.Version); + + await _singleFilePackageUpdater.UpdateAsync( + metadata, + packagePaths, + progress, + cancellationToken, + pauseController).ConfigureAwait(false); + } + + private async Task DownloadS3PackageAsync( + LauncherContent modification, + LauncherContentVersion version, + ManagedPackageSourceResolver.PackageSource.S3 source, + LauncherPaths paths, + IProgress? progress, + PackageDownloadPauseController pauseController, + CancellationToken cancellationToken) + { + _logger.LogInformation( + "Starting S3 package download for {ContentName} {ContentVersion}.", + version.Name, + version.Version); + + await pauseController.WaitWhilePausedAsync(cancellationToken).ConfigureAwait(false); + LauncherContentVersion? latestInstalledVersion = modification.LatestInstalledVersion; + PackageUpdatePathSet packagePaths = CreatePackagePaths( + paths, + version, + latestInstalledVersion); + + await _s3PackageUpdater.UpdateAsync( + new S3PackageUpdateRequest( + source.Files, + source.Request, + packagePaths, + S3HashValidationPolicy.InstallHashCheckedExtensions), + progress, + cancellationToken, + pauseController).ConfigureAwait(false); + } + + private static PackageUpdatePathSet CreatePackagePaths( + LauncherPaths paths, + LauncherContentVersion version, + LauncherContentVersion? latestInstalledVersion = null) + { + OwnedContentPath installedPath = ResolveVersionPath(paths, version); + OwnedContentPath? latestInstalledPath = latestInstalledVersion is null + ? null + : ResolveVersionPath(paths, latestInstalledVersion); + return PackageUpdatePathSet.Create( + paths, + installedPath, + latestInstalledPath); + } + + private static OwnedContentPath ResolveVersionPath( + LauncherPaths paths, + LauncherContentVersion version) + { + return LauncherContentPathResolver.ResolveVersionPath( + paths, + version.ContentKey) + ?? throw new InvalidOperationException( + "The package version did not resolve to a supported launcher content path."); + } + + private PackageDownloadResult CreateRecoverableProviderFailure( + LauncherContentVersion version, + Exception exception) + { + _logger.LogWarning( + exception, + "Remote package provider failed for {ContentName} {ContentVersion}.", + version.Name, + version.Version); + return PackageDownloadResult.RecoverableFailure( + "The remote package provider could not complete the download."); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Services/S3PackageUpdater.cs b/GenLauncherGO.Infrastructure/Updating/Services/S3PackageUpdater.cs new file mode 100644 index 00000000..76f1209f --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/S3PackageUpdater.cs @@ -0,0 +1,582 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.IO; +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; +using Minio; +using Minio.DataModel.Args; +using MinioClientFactory = GenLauncherGO.Infrastructure.Updating.Clients.MinioClientFactory; + +namespace GenLauncherGO.Infrastructure.Updating.Services; + +internal sealed class S3PackageUpdater : IS3PackageUpdater +{ + private const int MaxHashAttempts = 3; + private const int MaxConcurrentFileDownloads = 6; + private const int PresignedUrlLifetimeHours = 12; + + private readonly IResumableFileDownloader _fileDownloader; + private readonly IFileHashService _fileHashService; + private readonly ILogger _logger; + private readonly S3ReusablePackageFileCopier _reusableFileCopier; + + public S3PackageUpdater( + IResumableFileDownloader fileDownloader, + IFileHashService fileHashService, + ILogger logger) + { + _fileDownloader = fileDownloader ?? throw new ArgumentNullException(nameof(fileDownloader)); + _fileHashService = fileHashService ?? throw new ArgumentNullException(nameof(fileHashService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _reusableFileCopier = new S3ReusablePackageFileCopier(_fileHashService, _logger); + } + + /// + /// 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. + /// + public async Task UpdateAsync( + S3PackageUpdateRequest request, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(request.Source); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Source.AccessKey); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Source.SecretKey); + ArgumentNullException.ThrowIfNull(request.PathSet); + + await PackageDownloadPauseController.WaitWhilePausedAsync(pauseController, cancellationToken) + .ConfigureAwait(false); + PackageUpdatePathSet ownedPaths = request.PathSet; + string temporaryFolderPath = ownedPaths.TemporaryPath.FullPath; + EnsureUniqueManifestDestinations(temporaryFolderPath, request.Files); + PackageStagingFolderCleaner.RemoveUnsafeLinks( + ownedPaths.TemporaryPath, + _logger, + cancellationToken); + _logger.LogDebug( + "Starting S3 package update for bucket {BucketName}, folder {FolderName}; files: {FileCount}.", + request.Source.BucketName, + request.Source.Prefix, + request.Files.Count); + + if (ownedPaths.LatestInstalledPath is not null && + Directory.Exists(ownedPaths.LatestInstalledPath.FullPath)) + { + await _reusableFileCopier.CopyUnchangedFilesAsync( + ownedPaths.LatestInstalledPath, + ownedPaths.TemporaryPath, + request.Files, + cancellationToken).ConfigureAwait(false); + } + + await PackageDownloadPauseController.WaitWhilePausedAsync(pauseController, cancellationToken) + .ConfigureAwait(false); + await DownloadFilesAsync( + request.Files, + request.Source, + temporaryFolderPath, + request.HashCheckedExtensions, + progress, + pauseController, + cancellationToken).ConfigureAwait(false); + + await PackageDownloadPauseController.WaitWhilePausedAsync(pauseController, cancellationToken) + .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + PackageStagingFolderCleaner.PruneToManifest( + ownedPaths.TemporaryPath, + request.Files, + _logger, + cancellationToken); + + await PackageDownloadPauseController.WaitWhilePausedAsync(pauseController, cancellationToken) + .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + PackageInstallFolderReplacer.Replace( + ownedPaths.TemporaryPath, + ownedPaths.InstalledPath, + ownedPaths.BackupPath, + _logger); + PackageStagingFolderCleaner.DeleteEmptyPackageParents(ownedPaths.TemporaryPath, _logger); + _logger.LogDebug( + "Completed S3 package update for bucket {BucketName}, folder {FolderName}.", + request.Source.BucketName, + request.Source.Prefix); + } + + /// + /// Downloads selected S3 manifest files directly into an installed package folder, validating reliable hashes and + /// preserving unrelated installed files. + /// + public async Task RepairFilesAsync( + S3PackageFileRepairRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(request.Source); + ArgumentNullException.ThrowIfNull(request.InstalledPath); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Source.AccessKey); + ArgumentException.ThrowIfNullOrWhiteSpace(request.Source.SecretKey); + string installedFolderPath = request.InstalledPath.FullPath; + + EnsureUniqueManifestDestinations(installedFolderPath, request.Files); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + Path.GetDirectoryName(installedFolderPath) ?? request.InstalledPath.OwnerRoot, + "Installed package paths"); + Directory.CreateDirectory(installedFolderPath); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + installedFolderPath, + "Installed package paths"); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + installedFolderPath, + "Installed package paths"); + _logger.LogDebug( + "Starting S3 package file repair for bucket {BucketName}, folder {FolderName}; files: {FileCount}.", + request.Source.BucketName, + request.Source.Prefix, + request.Files.Count); + + await DownloadFilesAsync( + request.Files, + request.Source, + installedFolderPath, + request.HashCheckedExtensions, + progress, + null, + cancellationToken).ConfigureAwait(false); + _logger.LogDebug( + "Completed S3 package file repair for bucket {BucketName}, folder {FolderName}.", + request.Source.BucketName, + request.Source.Prefix); + } + + /// + /// Runs the shared resumable, validated, concurrent S3 transfer lifecycle for an update or in-place repair. + /// + private async Task DownloadFilesAsync( + IReadOnlyList files, + S3ObjectManifestRequest source, + string destinationFolderPath, + IReadOnlySet hashCheckedExtensions, + IProgress? progress, + PackageDownloadPauseController? pauseController, + CancellationToken cancellationToken) + { + List downloadWorkItems = await CreateDownloadWorkItemsAsync( + files, + destinationFolderPath, + hashCheckedExtensions, + cancellationToken).ConfigureAwait(false); + // Progress is measured against the whole package, not just what is left to fetch. Otherwise a resumed + // download reports a shrinking total and a bar that restarts at zero, both of which read as lost work. + long totalPackageSize = files.Sum(file => (long)file.Size); + long remainingDownloadSize = downloadWorkItems.Sum(download => download.BytesToDownload); + long resumedBytes = Math.Max(0, totalPackageSize - remainingDownloadSize); + var progressState = new PackageProgressTracker(totalPackageSize, resumedBytes); + if (downloadWorkItems.Count == 0) + { + progress?.Report(new PackageUpdateProgress(0, 0, 100, null)); + } + + // Disposed in the finally below rather than by `using` declarations: both are + // captured by the download tasks, so they must outlive every task instead of + // being released when this scope exits. + SemaphoreSlim downloadSlots = new(MaxConcurrentFileDownloads); + IMinioClient client = MinioClientFactory.Create( + source.Endpoint, + source.AccessKey, + source.SecretKey, + source.UseSsl); + + var downloadTasks = downloadWorkItems + .Select(download => DownloadFileWithSlotAsync( + client, + source.BucketName, + source.Prefix, + destinationFolderPath, + hashCheckedExtensions, + download, + progressState, + progress, + downloadSlots, + pauseController, + cancellationToken)) + .ToList(); + + try + { + await Task.WhenAll(downloadTasks).ConfigureAwait(false); + } + finally + { + // Task.WhenAll surfaces the first failure or cancellation immediately, while the + // remaining downloads are still running and still holding the semaphore and the + // client. Every task has to settle before either is released, rather than only + // the one that failed. + foreach (Task downloadTask in downloadTasks) + { + await downloadTask.ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + + client.Dispose(); + downloadSlots.Dispose(); + } + } + + /// + /// Creates the set of manifest files that still require remote transfer after reusable files are staged. + /// + private async Task> CreateDownloadWorkItemsAsync( + IReadOnlyList files, + string destinationFolderPath, + IReadOnlySet hashCheckedExtensions, + CancellationToken cancellationToken) + { + List downloads = []; + foreach (RemoteFileManifestEntry file in files) + { + string destinationFilePath = ManifestPathResolver.ResolvePath( + destinationFolderPath, + file.FileName); + EnsureSafeDestinationPath(destinationFolderPath, destinationFilePath); + if (await CheckFileSuccessDownloadAsync( + file, + destinationFilePath, + hashCheckedExtensions, + cancellationToken).ConfigureAwait(false)) + { + continue; + } + + 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; + } + + private static bool ExistingDownloadedFileMatchesExpectedSize( + RemoteFileManifestEntry file, + string destinationFilePath) + { + string existingFilePath = BigFileVariantPath.GetExistingDownloadedPath(destinationFilePath); + return !string.IsNullOrWhiteSpace(existingFilePath) && + new FileInfo(existingFilePath).Length == (long)file.Size; + } + + private async Task DownloadFileWithSlotAsync( + IMinioClient client, + string bucketName, + string folderName, + string destinationFolderPath, + IReadOnlySet hashCheckedExtensions, + S3DownloadWorkItem download, + PackageProgressTracker progressState, + IProgress? progress, + SemaphoreSlim downloadSlots, + PackageDownloadPauseController? pauseController, + CancellationToken cancellationToken) + { + await PackageDownloadPauseController.WaitWhilePausedAsync(pauseController, cancellationToken) + .ConfigureAwait(false); + await downloadSlots.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await DownloadVerifiedFileAsync( + client, + bucketName, + folderName, + destinationFolderPath, + hashCheckedExtensions, + download, + progressState, + progress, + pauseController, + cancellationToken).ConfigureAwait(false); + } + finally + { + downloadSlots.Release(); + } + } + + /// + /// Downloads a file, validates size and hash when available, and retries hash mismatches. + /// + private async Task DownloadVerifiedFileAsync( + IMinioClient client, + string bucketName, + string folderName, + string destinationFolderPath, + IReadOnlySet hashCheckedExtensions, + S3DownloadWorkItem download, + PackageProgressTracker progressState, + IProgress? progress, + PackageDownloadPauseController? pauseController, + CancellationToken cancellationToken) + { + RemoteFileManifestEntry file = download.File; + string destinationFilePath = ManifestPathResolver.ResolvePath( + destinationFolderPath, + file.FileName); + EnsureSafeDestinationPath(destinationFolderPath, destinationFilePath); + + 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); + string progressItemName = $"{file.FileName}#attempt-{attempt}"; + if (attempt > 1) + { + progressState.AddExpectedBytes((long)file.Size); + } + + Uri downloadUri = await BuildDownloadUriAsync( + client, + bucketName, + folderName, + file.FileName).ConfigureAwait(false); + IProgress downloadProgress = new InlineProgress(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; + long verifiedBytes = completed && expectedTransferBytes > 0 + ? Math.Min(transferredBytes, expectedTransferBytes - 1) + : transferredBytes; + ReportFileProgress( + progressState, + progress, + progressItemName, + verifiedBytes, + completed); + }); + + await _fileDownloader.DownloadFileAsync( + new DownloadFileRequest( + downloadUri, + destinationFilePath, + (long)file.Size, + true, + pauseController), + downloadProgress, + cancellationToken).ConfigureAwait(false); + + await PackageDownloadPauseController.WaitWhilePausedAsync(pauseController, cancellationToken) + .ConfigureAwait(false); + BigFileVariantPath.ConvertBigFileToGib(destinationFilePath); + + if (await CheckFileSuccessDownloadAsync( + file, + destinationFilePath, + hashCheckedExtensions, + cancellationToken).ConfigureAwait(false)) + { + ReportFileProgress( + progressState, + progress, + progressItemName, + expectedTransferBytes, + true); + return; + } + + if (attempt == MaxHashAttempts) + { + throw new IOException("Downloaded file validation failed after repeated download attempts."); + } + + // Account for the completed but invalid transfer without publishing a false terminal 100% report. + progressState.CompleteItemSilently(progressItemName, expectedTransferBytes); + _logger.LogWarning( + "Downloaded file validation failed for {FileName}; retrying download attempt {NextAttempt}.", + file.FileName, + attempt + 1); + DeleteFailedFile(destinationFilePath); + await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false); + } + } + + 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); + } + + private static async Task BuildDownloadUriAsync( + IMinioClient client, + string bucketName, + string folderName, + string fileName) + { + int expirySeconds = (int)Math.Min( + TimeSpan.FromHours(PresignedUrlLifetimeHours).TotalSeconds, + int.MaxValue); + string objectName = BuildObjectName(folderName, fileName); + PresignedGetObjectArgs args = new PresignedGetObjectArgs() + .WithBucket(bucketName) + .WithObject(objectName) + .WithExpiry(expirySeconds); + string presignedUrl = await client.PresignedGetObjectAsync(args).ConfigureAwait(false); + return new Uri(presignedUrl, UriKind.Absolute); + } + + 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. + /// + private void DeleteFailedFile(string destinationFilePath) + { + if (File.Exists(destinationFilePath)) + { + File.Delete(destinationFilePath); + _logger.LogDebug( + "Deleted failed downloaded file {FileName}.", + Path.GetFileName(destinationFilePath)); + } + + string gibFilePath = BigFileVariantPath.GetGibVariantPath(destinationFilePath); + if (File.Exists(gibFilePath)) + { + File.Delete(gibFilePath); + _logger.LogDebug( + "Deleted failed converted file {FileName}.", + Path.GetFileName(gibFilePath)); + } + } + + private static string BuildObjectName(string folderName, string fileName) + { + string normalizedFolderName = LexicalPath.NormalizeRelativePath(folderName); + string normalizedFileName = ManifestPathResolver.NormalizeForManifestIndex(fileName); + if (string.IsNullOrWhiteSpace(normalizedFolderName)) + { + return normalizedFileName; + } + + return $"{normalizedFolderName}/{normalizedFileName}"; + } + + 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); + } + } + + /// + /// Creates a manifest file's parent inside the package root and rejects linked path segments before file access. + /// + private static void EnsureSafeDestinationPath( + string packageRoot, + string destinationFilePath) + { + string destinationDirectory = Path.GetDirectoryName(destinationFilePath) ?? packageRoot; + if (!LexicalPath.AreEquivalent(packageRoot, destinationDirectory)) + { + OwnedDirectoryTree.EnsureExists(packageRoot, destinationDirectory); + } + + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + destinationFilePath, + "Package file paths"); + } + + /// + /// Rejects manifest aliases that would write or convert more than one object into the same local file. + /// + private static void EnsureUniqueManifestDestinations( + string packageRoot, + IReadOnlyList files) + { + HashSet destinations = new(StringComparer.OrdinalIgnoreCase); + foreach (RemoteFileManifestEntry file in files) + { + string destinationPath = ManifestPathResolver.ResolveInstalledPath(packageRoot, file.FileName); + + if (!destinations.Add(destinationPath)) + { + throw new InvalidDataException( + "The remote package manifest contains duplicate local file destinations."); + } + } + } + + 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..8076158f --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Services/SingleFilePackageUpdater.cs @@ -0,0 +1,137 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Archives.Contracts; +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.Updating.Services; + +internal sealed class SingleFilePackageUpdater : ISingleFilePackageUpdater +{ + private readonly IArchiveExtractor _archiveExtractor; + private readonly IResumableFileDownloader _fileDownloader; + private readonly ILogger _logger; + public SingleFilePackageUpdater( + IResumableFileDownloader fileDownloader, + IArchiveExtractor archiveExtractor, + ILogger logger) + { + _fileDownloader = fileDownloader ?? throw new ArgumentNullException(nameof(fileDownloader)); + _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. + /// + public async Task UpdateAsync( + DownloadFileMetadata metadata, + PackageUpdatePathSet paths, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null) + { + ArgumentNullException.ThrowIfNull(metadata); + ArgumentNullException.ThrowIfNull(paths); + + await PackageDownloadPauseController.WaitWhilePausedAsync(pauseController, cancellationToken) + .ConfigureAwait(false); + string temporaryFolderPath = paths.TemporaryPath.FullPath; + PackageStagingFolderCleaner.ClearDirectory(paths.TemporaryPath, _logger); + _logger.LogDebug( + "Starting single-file package update from {Host}.", + metadata.DownloadUri.Host); + + string destinationFilePath = ResolveMetadataFilePath( + temporaryFolderPath, + metadata.FileName); + bool extractionRequired = LauncherContentFileTypes.IsArchive(destinationFilePath); + var progressTracker = new PackageProgressTracker(metadata.TotalBytes); + + IProgress downloadProgress = new InlineProgress(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, + true, + pauseController), + downloadProgress, + cancellationToken).ConfigureAwait(false); + + await PackageDownloadPauseController.WaitWhilePausedAsync(pauseController, cancellationToken) + .ConfigureAwait(false); + if (extractionRequired) + { + await Task.Run( + () => _archiveExtractor.ExtractToDirectory( + destinationFilePath, + temporaryFolderPath, + true, + cancellationToken), + cancellationToken).ConfigureAwait(false); + + cancellationToken.ThrowIfCancellationRequested(); + if (File.Exists(destinationFilePath)) + { + File.Delete(destinationFilePath); + _logger.LogDebug( + "Deleted downloaded archive {FileName} after extraction.", + Path.GetFileName(destinationFilePath)); + } + } + + await PackageDownloadPauseController.WaitWhilePausedAsync(pauseController, cancellationToken) + .ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); + PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + _logger); + PackageStagingFolderCleaner.DeleteEmptyPackageParents(paths.TemporaryPath, _logger); + _logger.LogDebug("Completed single-file package update."); + } + + /// + /// Resolves an HTTP metadata file name as one direct child of the owned staging folder. + /// + private static string ResolveMetadataFilePath( + string temporaryFolderPath, + string fileName) + { + string safeFileName; + try + { + safeFileName = LexicalPath.NormalizePathSegment(fileName, nameof(fileName)); + } + catch (ArgumentException exception) + { + throw new InvalidDataException( + "Remote package metadata did not provide a safe direct file name.", + exception); + } + + return ManifestPathResolver.ResolvePath(temporaryFolderPath, safeFileName); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/DownloadLinkResolver.cs b/GenLauncherGO.Infrastructure/Updating/Support/DownloadLinkResolver.cs new file mode 100644 index 00000000..2a14cce8 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/DownloadLinkResolver.cs @@ -0,0 +1,76 @@ +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. +/// +internal static class DownloadLinkResolver +{ + public static Uri ResolveDownloadUri(string link) + { + return new Uri(ResolveDirectDownloadLink(link), UriKind.Absolute); + } + + /// + /// Converts supported share links into direct download links. + /// + /// + /// 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", StringComparison.Ordinal); + } + + 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. + /// + private static string ResolveOneDriveLink(string link) + { + if (link.Contains("embed", StringComparison.Ordinal)) + { + return link.Replace("embed", "download", StringComparison.Ordinal); + } + + List linkParts = [.. link.Replace("https://onedrive.live.com/?", string.Empty, StringComparison.Ordinal).Split('&')]; + string? cid = linkParts.Where(t => t.Contains("cid=", StringComparison.Ordinal)) + .Select(t => t.Replace("cid=", string.Empty, StringComparison.Ordinal)) + .FirstOrDefault(); + string? authKey = linkParts.Where(t => t.Contains("authkey=", StringComparison.Ordinal)) + .Select(t => t.Replace("authkey=", string.Empty, StringComparison.Ordinal)) + .FirstOrDefault(); + string? resid = linkParts.Where(t => + t.Contains("id=", StringComparison.Ordinal) && + !t.Contains("cid=", StringComparison.Ordinal)) + .Select(t => t.Replace("id=", string.Empty, StringComparison.Ordinal)) + .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/InlineProgress.cs b/GenLauncherGO.Infrastructure/Updating/Support/InlineProgress.cs new file mode 100644 index 00000000..63b55e1a --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/InlineProgress.cs @@ -0,0 +1,21 @@ +using System; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +/// +/// Invokes an internal progress callback inline so provider aggregation completes before the owning operation. +/// +internal sealed class InlineProgress : IProgress +{ + private readonly Action _report; + + public InlineProgress(Action report) + { + _report = report ?? throw new ArgumentNullException(nameof(report)); + } + + public void Report(T value) + { + _report(value); + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/MonotonicPackageProgress.cs b/GenLauncherGO.Infrastructure/Updating/Support/MonotonicPackageProgress.cs new file mode 100644 index 00000000..1d8f711d --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/MonotonicPackageProgress.cs @@ -0,0 +1,66 @@ +using System; +using System.Threading; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +/// +/// Normalizes concurrent provider reports so package progress never moves backwards. +/// +internal sealed class MonotonicPackageProgress : IProgress +{ + private readonly IProgress _inner; + private readonly Lock _syncRoot = new(); + + private long _bytesRead; + private double? _percentage; + private long? _totalBytes; + + public MonotonicPackageProgress(IProgress inner) + { + _inner = inner ?? throw new ArgumentNullException(nameof(inner)); + } + + public void Report(PackageUpdateProgress value) + { + ArgumentNullException.ThrowIfNull(value); + + lock (_syncRoot) + { + _bytesRead = Math.Max(_bytesRead, Math.Max(0, value.BytesRead)); + if (value.TotalBytes.HasValue) + { + _totalBytes = Math.Max(_totalBytes ?? 0, Math.Max(0, value.TotalBytes.Value)); + } + + if (_totalBytes.HasValue && _bytesRead > _totalBytes.Value) + { + _totalBytes = _bytesRead; + } + + double? percentage = value.ProgressPercentage; + if (!percentage.HasValue && _totalBytes is > 0) + { + percentage = (double)_bytesRead / _totalBytes.Value * 100D; + } + + if (percentage.HasValue) + { + _percentage = Math.Max( + _percentage ?? 0D, + Math.Clamp(percentage.Value, 0D, 100D)); + } + + PackageUpdateProgress normalized = value with + { + TotalBytes = _totalBytes, + BytesRead = _bytesRead, + ProgressPercentage = _percentage + }; + + // Keep normalization and delivery ordered for concurrent S3 reporters. The caller still owns dispatch + // semantics; the inner reporter posts these ordered values to the UI context. + _inner.Report(normalized); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/PackageInstallFolderReplacer.cs b/GenLauncherGO.Infrastructure/Updating/Support/PackageInstallFolderReplacer.cs new file mode 100644 index 00000000..232770a9 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/PackageInstallFolderReplacer.cs @@ -0,0 +1,322 @@ +using System; +using System.IO; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Common; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +/// +/// Replaces an installed package folder through a staged move with rollback and restart recovery. +/// +internal static class PackageInstallFolderReplacer +{ + /// + /// Replaces an installed folder using explicit launcher ownership boundaries. + /// + public static void Replace( + OwnedContentPath temporaryPath, + OwnedContentPath installedPath, + OwnedContentPath backupPath, + ILogger logger) + { + Replace( + temporaryPath, + installedPath, + backupPath, + logger, + ownedBackupPath => OwnedDirectoryTree.DeleteIfExists(ownedBackupPath)); + } + + /// + /// Replaces an installed folder and delegates recovery-backup cleanup through a focused test seam. + /// + internal static void Replace( + OwnedContentPath temporaryPath, + OwnedContentPath installedPath, + OwnedContentPath backupPath, + ILogger logger, + Action deleteBackup) + { + ArgumentNullException.ThrowIfNull(temporaryPath); + ArgumentNullException.ThrowIfNull(installedPath); + ArgumentNullException.ThrowIfNull(backupPath); + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(deleteBackup); + + EnsureRecoveryPathDoesNotOverlapContent(temporaryPath, installedPath, backupPath); + + string temporaryFolderPath = temporaryPath.FullPath; + string installedFolderPath = installedPath.FullPath; + EnsureParentDirectoryExists( + installedPath, + "Installed package paths", + "launcher-owned content", + logger); + + EnsureDirectoryPathHasNoReparsePoints( + installedPath, + "Installed package paths", + logger); + EnsureDirectoryPathHasNoReparsePoints( + backupPath, + "Package backup paths", + logger); + ReconcilePreviousReplacement(installedPath, backupPath, logger, deleteBackup); + EnsureDirectoryPathHasNoReparsePoints( + temporaryPath, + "Temporary package paths", + logger); + + if (!Directory.Exists(temporaryFolderPath)) + { + throw new DirectoryNotFoundException( + $"Temporary package folder '{temporaryFolderPath}' was not found."); + } + + bool backupCreated = false; + try + { + EnsureDirectoryPathHasNoReparsePoints( + temporaryPath, + "Temporary package paths", + logger); + EnsureDirectoryPathHasNoReparsePoints( + installedPath, + "Installed package paths", + logger); + EnsureDirectoryPathHasNoReparsePoints( + backupPath, + "Package backup paths", + logger); + if (Directory.Exists(installedFolderPath)) + { + EnsureParentDirectoryExists( + backupPath, + "Package backup paths", + "launcher-owned recovery state", + logger); + logger.LogDebug( + "Moving existing installed package folder {InstalledFolderName} to a staged backup.", + Path.GetFileName(installedFolderPath)); + Directory.Move(installedFolderPath, backupPath.FullPath); + backupCreated = true; + } + + EnsureDirectoryPathHasNoReparsePoints( + temporaryPath, + "Temporary package paths", + logger); + logger.LogDebug( + "Moving temporary package folder {TemporaryFolderName} into installed package location {InstalledFolderName}.", + Path.GetFileName(temporaryFolderPath), + Path.GetFileName(installedFolderPath)); + Directory.Move(temporaryFolderPath, installedFolderPath); + } + catch + { + RollBackReplacement( + temporaryFolderPath, + installedFolderPath, + backupPath, + backupCreated, + logger); + throw; + } + + if (!backupCreated) + { + return; + } + + try + { + deleteBackup(backupPath); + DeleteEmptyBackupParents(backupPath, logger); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException or InvalidDataException) + { + logger.LogWarning( + exception, + "Package replacement committed, but obsolete recovery-state cleanup failed for {InstalledFolderName}. Any durable recovery backup will be reconciled on the next replacement.", + Path.GetFileName(installedFolderPath)); + } + } + + /// + /// Resolves the durable backup left by an interrupted or committed replacement before another mutation. + /// + private static void ReconcilePreviousReplacement( + OwnedContentPath installedPath, + OwnedContentPath backupPath, + ILogger logger, + Action deleteBackup) + { + if (!Directory.Exists(backupPath.FullPath)) + { + return; + } + + if (!Directory.Exists(installedPath.FullPath)) + { + logger.LogWarning( + "Restoring interrupted package replacement for {InstalledFolderName} from its recovery backup.", + Path.GetFileName(installedPath.FullPath)); + Directory.Move(backupPath.FullPath, installedPath.FullPath); + DeleteEmptyBackupParents(backupPath, logger); + return; + } + + logger.LogInformation( + "Removing stale recovery backup for committed package {InstalledFolderName}.", + Path.GetFileName(installedPath.FullPath)); + deleteBackup(backupPath); + if (Directory.Exists(backupPath.FullPath)) + { + throw new IOException( + $"The stale recovery backup for package '{Path.GetFileName(installedPath.FullPath)}' could not be removed."); + } + + DeleteEmptyBackupParents(backupPath, logger); + } + + /// + /// Rejects recovery paths that overlap installed content or temporary staging. + /// + private static void EnsureRecoveryPathDoesNotOverlapContent( + OwnedContentPath temporaryPath, + OwnedContentPath installedPath, + OwnedContentPath backupPath) + { + if (PathsOverlap(backupPath.FullPath, installedPath.FullPath) || + PathsOverlap(backupPath.FullPath, temporaryPath.FullPath)) + { + throw new ArgumentException( + "Package recovery backup paths must not overlap installed or temporary package content.", + nameof(backupPath)); + } + } + + private static bool PathsOverlap(string firstPath, string secondPath) + { + return LexicalPath.IsPathInDirectory(firstPath, secondPath) || + LexicalPath.IsPathInDirectory(secondPath, firstPath); + } + + /// + /// Creates an owned parent directory only after verifying its existing path chain is not linked. + /// + private static void EnsureParentDirectoryExists( + OwnedContentPath directoryPath, + string pathSubject, + string ownerDescription, + ILogger logger) + { + try + { + string parentDirectory = Path.GetDirectoryName(directoryPath.FullPath) + ?? throw new InvalidDataException( + $"{pathSubject} must have a parent directory."); + FileSystemPathSafety.ResolveOwnedSubpath( + directoryPath.OwnerRoot, + parentDirectory, + pathSubject, + ownerDescription); + Directory.CreateDirectory(parentDirectory); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints(parentDirectory, pathSubject); + } + catch (InvalidDataException exception) + { + logger.LogWarning( + exception, + "Blocked package folder replacement because the parent path for {FolderName} failed path-safety validation.", + Path.GetFileName(directoryPath.FullPath)); + + // Surface the check's own message rather than a fixed one, so the IOException names the safety rule + // that actually rejected the path. + throw new IOException(exception.Message, exception); + } + } + + /// + /// Verifies that an install or staging directory path does not cross or contain links before replacement. + /// + private static void EnsureDirectoryPathHasNoReparsePoints( + OwnedContentPath directoryPath, + string pathSubject, + ILogger logger) + { + try + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + directoryPath.FullPath, + pathSubject); + if (Directory.Exists(directoryPath.FullPath)) + { + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + directoryPath.FullPath, + pathSubject); + } + } + catch (InvalidDataException exception) + { + logger.LogWarning( + exception, + "Blocked package folder replacement because {FolderName} contains a reparse point.", + Path.GetFileName(directoryPath.FullPath)); + + // Surface the check's own message rather than a fixed one, so the IOException names the safety rule + // that actually rejected the path. + throw new IOException(exception.Message, exception); + } + } + + /// + /// Attempts to restore the prior installed folder after a staged replacement failure. + /// + private static void RollBackReplacement( + string temporaryPath, + string installedPath, + OwnedContentPath backupPath, + bool backupCreated, + ILogger logger) + { + if (!backupCreated || !Directory.Exists(backupPath.FullPath) || Directory.Exists(installedPath)) + { + return; + } + + try + { + logger.LogWarning( + "Rolling back package folder replacement for {InstalledFolderName}.", + Path.GetFileName(installedPath)); + Directory.Move(backupPath.FullPath, installedPath); + DeleteEmptyBackupParents(backupPath, logger); + } + catch (Exception exception) + { + logger.LogError( + exception, + "Failed to roll back package folder replacement for {InstalledFolderName}. Temporary folder exists: {TemporaryFolderExists}", + Path.GetFileName(installedPath), + Directory.Exists(temporaryPath)); + } + } + + /// + /// Prunes the empty backup hierarchy, including its exclusive ownership boundary when no backups remain. + /// + private static void DeleteEmptyBackupParents(OwnedContentPath backupPath, ILogger logger) + { + foreach (string deletedDirectory in OwnedDirectoryTree.DeleteEmptyParentsIncludingRoot( + backupPath.OwnerRoot, + backupPath.FullPath)) + { + logger.LogDebug( + "Deleted empty launcher package recovery folder {BackupFolderName}.", + Path.GetFileName(deletedDirectory)); + } + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/PackageProgressTracker.cs b/GenLauncherGO.Infrastructure/Updating/Support/PackageProgressTracker.cs new file mode 100644 index 00000000..99004dce --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/PackageProgressTracker.cs @@ -0,0 +1,134 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +internal sealed class PackageProgressTracker +{ + private static readonly TimeSpan _reportInterval = TimeSpan.FromMilliseconds(100); + + private readonly Dictionary _itemProgressBytes = new(StringComparer.OrdinalIgnoreCase); + private readonly Lock _progressGate = new(); + + private readonly long _resumedBytes; + private readonly long _startTimestamp; + private readonly TimeProvider _timeProvider; + + private TimeSpan _lastReportElapsed; + private long _lastReportedBytesRead; + private double? _lastReportedPercentage; + private long? _totalBytes; + private long _totalBytesRead; + + /// + /// Creates a tracker for one package transfer. + /// + /// The package's full size, including whatever is already on disk. + /// + /// Bytes already present when this transfer started. They count towards progress, so a resumed download picks + /// up where it stopped instead of restarting at zero, but never towards the transfer rate: nothing moved them + /// during this session, and crediting them would report a speed and an estimate no connection is achieving. + /// + /// The clock used for rate and estimate calculations. + public PackageProgressTracker(long? totalBytes, long resumedBytes = 0, TimeProvider? timeProvider = null) + { + _totalBytes = totalBytes; + _resumedBytes = Math.Max(0, resumedBytes); + _totalBytesRead = _resumedBytes; + _timeProvider = timeProvider ?? TimeProvider.System; + _startTimestamp = _timeProvider.GetTimestamp(); + } + + public void AddExpectedBytes(long bytes) + { + if (bytes <= 0) + { + return; + } + + lock (_progressGate) + { + if (_totalBytes.HasValue) + { + _totalBytes += bytes; + } + } + } + + public void CompleteItemSilently( + string itemName, + long bytesRead) + { + lock (_progressGate) + { + UpdateItemProgress(itemName, bytesRead); + } + } + + public PackageUpdateProgress? Update( + string itemName, + long bytesRead, + bool forceReport = false) + { + lock (_progressGate) + { + UpdateItemProgress(itemName, bytesRead); + + TimeSpan elapsed = _timeProvider.GetElapsedTime(_startTimestamp); + 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.Clamp( + Math.Round((double)_totalBytesRead / totalBytes.Value * 100, 2), + 0D, + 100D); + progressPercentage = Math.Max(_lastReportedPercentage ?? 0D, progressPercentage.Value); + _lastReportedPercentage = progressPercentage; + } + + double? speedBytesPerSecond = null; + TimeSpan? estimatedTimeRemaining = null; + long transferredThisSession = _totalBytesRead - _resumedBytes; + if (elapsed.TotalSeconds > 0.25 && transferredThisSession > 0) + { + speedBytesPerSecond = transferredThisSession / 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); + } + } + + private void UpdateItemProgress(string itemName, long bytesRead) + { + long previousBytesRead = _itemProgressBytes.GetValueOrDefault(itemName); + long normalizedBytesRead = Math.Max(previousBytesRead, Math.Max(0, bytesRead)); + _itemProgressBytes[itemName] = normalizedBytesRead; + _totalBytesRead += normalizedBytesRead - previousBytesRead; + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/PackageStagingFolderCleaner.cs b/GenLauncherGO.Infrastructure/Updating/Support/PackageStagingFolderCleaner.cs new file mode 100644 index 00000000..3a7be0dd --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/PackageStagingFolderCleaner.cs @@ -0,0 +1,170 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Updating.Models; +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 an explicitly launcher-owned staging folder. + /// + public static void ClearDirectory(OwnedContentPath stagingPath, ILogger logger) + { + ArgumentNullException.ThrowIfNull(stagingPath); + ArgumentNullException.ThrowIfNull(logger); + + string stagingRoot = OwnedDirectoryTree.PrepareEmpty(stagingPath.OwnerRoot, stagingPath.FullPath); + + logger.LogDebug( + "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. + /// + public static void DeleteEmptyPackageParents(OwnedContentPath stagingPath, ILogger logger) + { + ArgumentNullException.ThrowIfNull(stagingPath); + ArgumentNullException.ThrowIfNull(logger); + + var packagesDirectory = new DirectoryInfo(stagingPath.OwnerRoot); + if (!string.Equals( + packagesDirectory.Name, + LauncherFileSystemLayout.PackagesFolderName, + StringComparison.OrdinalIgnoreCase) || + packagesDirectory.Parent is null) + { + return; + } + + try + { + foreach (string deletedDirectory in OwnedDirectoryTree.DeleteEmptyParents( + packagesDirectory.Parent.FullName, + stagingPath.FullPath)) + { + logger.LogDebug( + "Deleted empty package staging folder {StagingFolderName}.", + Path.GetFileName(deletedDirectory)); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + logger.LogWarning( + exception, + "Failed to delete empty package staging parents for {StagingFolderName}.", + Path.GetFileName(stagingPath.FullPath)); + } + } + + /// + /// Deletes reparse points from an explicitly launcher-owned staging folder without following them. + /// + public static void RemoveUnsafeLinks( + OwnedContentPath stagingPath, + ILogger logger, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(stagingPath); + ArgumentNullException.ThrowIfNull(logger); + + bool replacedRootLink = Directory.Exists(stagingPath.FullPath) && + FileSystemPathSafety.IsReparsePoint(stagingPath.FullPath); + string stagingRoot = OwnedDirectoryTree.EnsureRealDirectory( + stagingPath.OwnerRoot, + stagingPath.FullPath); + if (replacedRootLink) + { + logger.LogWarning( + "Removed unsafe staging-root link {StagingFolderName}.", + Path.GetFileName(stagingRoot)); + } + + cancellationToken.ThrowIfCancellationRequested(); + foreach (string deletedPath in OwnedDirectoryTree.DeleteReparsePoints(stagingPath)) + { + cancellationToken.ThrowIfCancellationRequested(); + logger.LogWarning( + "Removed unsafe staging link {EntryName}.", + Path.GetFileName(deletedPath)); + } + } + + /// + /// Deletes staged files that are not expected by the remote manifest from an explicitly owned staging path. + /// + public static void PruneToManifest( + OwnedContentPath stagingPath, + IReadOnlyList files, + ILogger logger, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(stagingPath); + ArgumentNullException.ThrowIfNull(files); + ArgumentNullException.ThrowIfNull(logger); + + string stagingRoot = OwnedDirectoryTree.EnsureRealDirectory( + stagingPath.OwnerRoot, + stagingPath.FullPath); + RemoveUnsafeLinks(stagingPath, logger, cancellationToken); + + HashSet expectedPaths = BuildExpectedInstalledPaths(stagingRoot, files); + foreach (string filePath in Directory + .EnumerateFiles(stagingRoot, "*", FileSystemPathSafety.CreateRecursiveNoLinksOptions()) + .ToList()) + { + cancellationToken.ThrowIfCancellationRequested(); + + string fullPath = LexicalPath.NormalizeFullPath(filePath); + if (expectedPaths.Contains(fullPath)) + { + continue; + } + + File.Delete(fullPath); + logger.LogDebug( + "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); + } + } + } + + private static HashSet BuildExpectedInstalledPaths( + string stagingRoot, + IReadOnlyList files) + { + HashSet expectedPaths = new(StringComparer.OrdinalIgnoreCase); + foreach (RemoteFileManifestEntry file in files) + { + string destinationPath = ManifestPathResolver.ResolveInstalledPath(stagingRoot, file.FileName); + expectedPaths.Add(LexicalPath.NormalizeFullPath(destinationPath)); + } + + return expectedPaths; + } +} diff --git a/GenLauncherGO.Infrastructure/Updating/Support/S3CatalogDefaults.cs b/GenLauncherGO.Infrastructure/Updating/Support/S3CatalogDefaults.cs new file mode 100644 index 00000000..35f87978 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/S3CatalogDefaults.cs @@ -0,0 +1,74 @@ +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. +/// +internal 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. + /// + public static S3ObjectManifestRequest CreateManifestRequest(LauncherContentVersion 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. + /// + public static string ResolveAccessKey(LauncherContentVersion 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. + /// + public static string ResolveSecretKey(LauncherContentVersion 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..b34c8e23 --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/S3HashValidationPolicy.cs @@ -0,0 +1,81 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Infrastructure.Updating.Support; + +/// +/// Determines when S3 package files should be validated with reliable MD5 hashes. +/// +internal static class S3HashValidationPolicy +{ + /// + /// Gets the legacy-compatible file kinds whose manifest MD5 is checked during a normal installation. + /// + public static IReadOnlySet InstallHashCheckedExtensions { get; } = + new HashSet(StringComparer.OrdinalIgnoreCase) + { + ".w3d", + LauncherContentFileTypes.BigExtension, + ".bik", + LauncherContentFileTypes.GibExtension, + ".dds", + ".tga", + ".ini", + ".scb", + ".wnd", + ".csf", + ".str" + }; + + /// + /// Builds the complete extension set used while repairing an integrity failure already observed on disk. + /// + public static IReadOnlySet CreateRepairHashCheckedExtensions( + IEnumerable files) + { + ArgumentNullException.ThrowIfNull(files); + + var extensions = files + .Select(file => Path.GetExtension(file.FileName)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + extensions.Add(LauncherContentFileTypes.GibExtension); + return extensions; + } + + /// + /// Returns whether a manifest entry should be validated with MD5. + /// + 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. + /// + 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..55703a7d --- /dev/null +++ b/GenLauncherGO.Infrastructure/Updating/Support/S3ReusablePackageFileCopier.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.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 +{ + private readonly IFileHashService _fileHashService; + + private readonly ILogger _logger; + + public S3ReusablePackageFileCopier( + IFileHashService fileHashService, + ILogger logger) + { + _fileHashService = fileHashService ?? throw new ArgumentNullException(nameof(fileHashService)); + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + public async Task CopyUnchangedFilesAsync( + OwnedContentPath sourcePath, + OwnedContentPath destinationPath, + IReadOnlyList repositoryFiles, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(sourcePath); + ArgumentNullException.ThrowIfNull(destinationPath); + ArgumentNullException.ThrowIfNull(repositoryFiles); + + FileSystemPathSafety.ResolveOwnedSubpath( + sourcePath.OwnerRoot, + sourcePath.FullPath, + "Reusable package paths", + "launcher-owned content"); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + sourcePath.FullPath, + "Reusable package paths"); + FileSystemPathSafety.ResolveOwnedSubpath( + destinationPath.OwnerRoot, + destinationPath.FullPath, + "Package staging paths", + "launcher-owned temporary storage"); + FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints( + destinationPath.FullPath, + "Package staging paths"); + + Dictionary repositoryFileIndex = BuildRepositoryFileIndex(repositoryFiles); + await CopyReusableDirectoryContentAsync( + sourcePath.FullPath, + destinationPath.FullPath, + repositoryFileIndex, + string.Empty, + cancellationToken).ConfigureAwait(false); + } + + /// + /// Builds a manifest lookup keyed by normalized relative paths and converted .gib aliases. + /// + 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; + + string installedPath = + ManifestPathResolver.NormalizeInstalledPathForManifestIndex(repositoryFile.FileName); + if (!string.Equals(installedPath, normalizedPath, StringComparison.OrdinalIgnoreCase)) + { + repositoryFileIndex[installedPath] = repositoryFile; + } + } + + return repositoryFileIndex; + } + + private async Task CopyReusableDirectoryContentAsync( + string sourceDir, + string destinationDir, + Dictionary repositoryFileIndex, + string pathAddition, + CancellationToken cancellationToken) + { + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + sourceDir, + "Reusable package paths"); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + destinationDir, + "Package staging paths"); + 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); + } + + foreach (DirectoryInfo subDir in directories) + { + cancellationToken.ThrowIfCancellationRequested(); + + await CopyReusableDirectoryContentAsync( + subDir.FullName, + Path.Combine(destinationDir, subDir.Name), + repositoryFileIndex, + ManifestPathResolver.NormalizeForManifestIndex(Path.Combine(pathAddition, subDir.Name)), + cancellationToken).ConfigureAwait(false); + } + } + + private async Task CopyReusableFileAsync( + FileInfo file, + string destinationDir, + Dictionary repositoryFileIndex, + string pathAddition, + CancellationToken cancellationToken) + { + string targetFilePath = ManifestPathResolver.ResolvePath(destinationDir, file.Name); + string relativeFilePath = ManifestPathResolver.NormalizeForManifestIndex( + Path.Combine(pathAddition, file.Name)); + + RemoteFileManifestEntry? repositoryFile = repositoryFileIndex.GetValueOrDefault(relativeFilePath); + if (File.Exists(targetFilePath) || repositoryFile is null) + { + return; + } + + if (!S3HashValidationPolicy.IsReliableMd5Hash(repositoryFile.Hash)) + { + return; + } + + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + file.FullName, + "Reusable package paths"); + // Keep the source handle open so Windows denies writes and replacement until the verified bytes are copied. + await using FileStream sourceStream = file.OpenRead(); + if (repositoryFile.Size != (ulong)sourceStream.Length) + { + return; + } + + string hash = await _fileHashService + .ComputeMd5HashAsync(file.FullName, cancellationToken) + .ConfigureAwait(false); + if (!string.Equals(hash, repositoryFile.Hash, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + file.FullName, + "Reusable package paths"); + FileSystemPathSafety.EnsureExistingPathChainHasNoReparsePoints( + destinationDir, + "Package staging paths"); + 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.TestAnalyzers/GenLauncherGO.TestAnalyzers.csproj b/GenLauncherGO.TestAnalyzers/GenLauncherGO.TestAnalyzers.csproj new file mode 100644 index 00000000..2f231d72 --- /dev/null +++ b/GenLauncherGO.TestAnalyzers/GenLauncherGO.TestAnalyzers.csproj @@ -0,0 +1,14 @@ + + + net10.0 + false + false + + + + + $(MSBuildToolsPath)\Roslyn\bincore\Microsoft.CodeAnalysis.dll + false + + + diff --git a/GenLauncherGO.TestAnalyzers/TestMethodNameConvention.cs b/GenLauncherGO.TestAnalyzers/TestMethodNameConvention.cs new file mode 100644 index 00000000..ff78926e --- /dev/null +++ b/GenLauncherGO.TestAnalyzers/TestMethodNameConvention.cs @@ -0,0 +1,15 @@ +using System.Text.RegularExpressions; + +namespace GenLauncherGO.TestAnalyzers; + +internal static class TestMethodNameConvention +{ + private static readonly Regex _validName = new( + "^[A-Z][A-Za-z0-9]*_[A-Z][A-Za-z0-9]*(?:_[A-Z][A-Za-z0-9]*)?$", + RegexOptions.CultureInvariant); + + public static bool IsValid(string name) + { + return _validName.IsMatch(name); + } +} diff --git a/GenLauncherGO.TestAnalyzers/TestMethodNamingAnalyzer.cs b/GenLauncherGO.TestAnalyzers/TestMethodNamingAnalyzer.cs new file mode 100644 index 00000000..556d6ce4 --- /dev/null +++ b/GenLauncherGO.TestAnalyzers/TestMethodNamingAnalyzer.cs @@ -0,0 +1,65 @@ +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; + +namespace GenLauncherGO.TestAnalyzers; + +/// +/// Enforces the repository's behavior-oriented xUnit test naming convention. +/// +[DiagnosticAnalyzer(LanguageNames.CSharp)] +public sealed class TestMethodNamingAnalyzer : DiagnosticAnalyzer +{ + public const string DiagnosticId = "GLT001"; + + private static readonly DiagnosticDescriptor _rule = new( + DiagnosticId, + "Test method name must describe behavior", + "Test method '{0}' must use 'MemberOrBehavior_ExpectedOutcome' or " + + "'MemberOrBehavior_Scenario_ExpectedOutcome'", + "Naming", + DiagnosticSeverity.Error, + true, + "xUnit test names use two or three PascalCase segments so behavior and expected outcome remain scannable."); + + public override ImmutableArray SupportedDiagnostics => [_rule]; + + public override void Initialize(AnalysisContext context) + { + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + context.EnableConcurrentExecution(); + context.RegisterSymbolAction(AnalyzeMethod, SymbolKind.Method); + } + + private static void AnalyzeMethod(SymbolAnalysisContext context) + { + var method = (IMethodSymbol)context.Symbol; + if (!IsXunitTestMethod(method) || TestMethodNameConvention.IsValid(method.Name)) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create( + _rule, + method.Locations[0], + method.Name)); + } + + private static bool IsXunitTestMethod(IMethodSymbol method) + { + foreach (AttributeData attribute in method.GetAttributes()) + { + for (INamedTypeSymbol? attributeType = attribute.AttributeClass; + attributeType is not null; + attributeType = attributeType.BaseType) + { + if (attributeType.ToDisplayString() == "Xunit.FactAttribute") + { + return true; + } + } + } + + return false; + } +} diff --git a/GenLauncherGO.Tests/AGENTS.md b/GenLauncherGO.Tests/AGENTS.md new file mode 100644 index 00000000..089864c9 --- /dev/null +++ b/GenLauncherGO.Tests/AGENTS.md @@ -0,0 +1,63 @@ +# GenLauncherGO.Tests Guidance + +- Prefer a small handwritten fake over a substitute when it makes stateful behavior clearer. +- Test observable behavior, safety, compatibility mappings, and important invariants. Do not test auto-properties, + standard guards, framework behavior, private helpers, or DI descriptors individually. +- Keep headless Avalonia tests semantic: verify compiled AXAML loads and that meaningful user states expose the expected + content, actions, accessibility, and theme resources. Protect exact appearance with the smallest practical rendered or + golden-image coverage, not assertions over coordinates, margins, grid positions, control dimensions, template-part + structure, or internal visual-tree shape. +- Do not use real-time animation midpoint assertions, no-throw framework smoke tests, or one test per obvious property, + factory, or guard. Test application-owned state transitions and outcomes instead. +- Keep one focused composition test; do not mirror every registration. +- Use isolated temporary directories for file-system tests. Never require a real game installation, live network + service, or production credential. +- Protect exact remote YAML binding and its single mapping into normalized concepts with representative fixtures. +- Reuse shared builders, fakes, the Avalonia headless UI runner, and canonical authorities instead of copying setup or + expected constants. +- Structure tests as arrange, act, and assert separated by blank lines. Add phase comments only when a boundary is + genuinely ambiguous; repeated act phases or branching assertions normally mean the behavior should be split. +- Use surviving mutants to find missing behavior assertions; do not add assertions whose only purpose is raising a score. +- Reach for a shared helper in `Testing/` before writing setup. `GlobalUsings.cs` already imports that namespace, so + no `using` is needed. Add a helper there only once a second caller exists. + +## Naming helpers in `Testing/` + +The prefix states what the helper does, so a reader knows from the call site whether it holds state, answers fixed +values, or is there to assert on. + +| Prefix | Means | +| --- | --- | +| `Fake` | A hand-written working implementation, simplified but with real behavior and state. | +| `Recording` | Captures the calls a test asserts on, exposed as `List<>` properties. | +| `Stub` | Answers with fixed values and records nothing. | +| `Controllable` | The test decides when the operation completes, usually through a `TaskCompletionSource`. | +| `Test` | Builds inputs — paths, content, view models. Not a test double. | + +A helper whose own name says more than the prefix would keeps that name instead: `CompletedGameProcessLaunchOperation`, +`QueueHttpMessageHandler`, `ManualTimeProvider`. Scopes that restore state on dispose end in `Scope`. + +## Mutation testing + +`eng/mutation.proj` runs one Stryker configuration per production area, each with its own break threshold, so a +weakly covered area cannot hide behind a strong one. Together they cover every behavior-bearing file in Core and +Infrastructure. + +`GenLauncherGO.UI` has no configuration and cannot have one: Avalonia emits `InitializeComponent` and the `x:Name` +backing fields from a Roslyn source generator, and Stryker recompiles from parsed syntax trees without running +generators, so every `.axaml.cs` fails with CS0103 and the run aborts. Do not add a UI configuration expecting it to +work. UI quality rests on the coverage backstop and on behavioral tests. + +Some mutants stay alive on purpose. Before adding an assertion to kill one, classify it: + +- **Equivalent** — the mutated program is genuinely indistinguishable, e.g. `new UTF8Encoding(false)` versus `true`, + because `GetBytes` never emits a preamble. Leave it. +- **Unobservable** — real but undetectable from a behavior test: durability flags such as `Flush(true)`, buffer sizes, + `FileOptions` bit combinations, `File.Replace` metadata flags, and exception message text. Asserting message strings + is barred above, so these stay. Leave them. +- **Needs a production change** — an unreachable branch or a missing seam. Raise it as its own decision; do not + contort a test around it. +- **Killable** — the mutation changes something a caller or user observes. This is the only kind worth work. + +`ignore-methods` in each configuration already filters logging, argument guards, and `ConfigureAwait`; the thresholds +account for the residue that cannot be filtered. diff --git a/GenLauncherGO.Tests/Conventions/AsynchronousMethodNamingTests.cs b/GenLauncherGO.Tests/Conventions/AsynchronousMethodNamingTests.cs new file mode 100644 index 00000000..7bb0e6b1 --- /dev/null +++ b/GenLauncherGO.Tests/Conventions/AsynchronousMethodNamingTests.cs @@ -0,0 +1,123 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Infrastructure.Logging; +using GenLauncherGO.UI.Features.Startup; + +namespace GenLauncherGO.Tests.Conventions; + +/// +/// Holds the Async suffix on awaitable-returning methods across the production assemblies. +/// +/// +/// The `dotnet_naming_rule` in .editorconfig only reaches methods carrying the `async` keyword, +/// because a naming style cannot inspect a return type. A method that returns a task without +/// being declared `async` — a forwarder, or one ending in a single `return SomethingAsync(...)` — +/// is invisible to it, and no CA rule covers the gap either. Reflection does, so the convention +/// is enforced here rather than left to reviewers. +/// +public sealed class AsynchronousMethodNamingTests +{ + /// + /// The methods named for the task they return rather than for work to await. + /// + /// + /// Such a method is a synchronous accessor handing back a handle to work already in flight. Naming + /// GetActiveDownloadTask as GetActiveDownloadTaskAsync would claim the getter itself is + /// asynchronous, which is the opposite of what it does. The exemption is written out member by member so a + /// newly added ...Task method has to be justified rather than excused by its suffix. + /// + private static readonly string[] _taskHandleAccessors = + [ + "GenLauncherGO.UI.Features.Integrity.LauncherPackageActivityService.GetActiveDownloadTask" + ]; + + [Fact] + public void EveryAwaitableReturningProductionMethod_EndsWithAsync() + { + var offenders = ScanAwaitableReturningMethods() + .Where(method => !method.EndsWith("Async", StringComparison.Ordinal)) + .Where(method => !_taskHandleAccessors.Contains(method, StringComparer.Ordinal)) + .ToList(); + + offenders.Should().BeEmpty( + "a method returning Task or ValueTask must end in Async so callers can see it needs awaiting"); + } + + [Fact] + public void NamingScan_CoversEveryAwaitableShapeItClaimsToGuard() + { + // A scan that silently matched nothing would let the test above pass while checking nothing at all, and + // one that missed a task shape would leave that shape unguarded just as quietly. + Type[] awaitableReturnTypes = [typeof(Task), typeof(Task), typeof(ValueTask), typeof(ValueTask)]; + Type[] otherReturnTypes = [typeof(void), typeof(IAsyncEnumerable)]; + + List awaitableReturningMethods = ScanAwaitableReturningMethods(); + + awaitableReturningMethods.Should().Contain( + "GenLauncherGO.Infrastructure.Mods.Services.LauncherContentCatalogService.InitDataAsync", + "the scan must reach the production assemblies it guards"); + awaitableReturnTypes.Should().OnlyContain(returnType => ReturnsAwaitable(returnType)); + otherReturnTypes.Should().NotContain(returnType => ReturnsAwaitable(returnType)); + } + + /// + /// Lists every awaitable-returning production method a person named, as + /// Namespace.Type.Method. + /// + private static List ScanAwaitableReturningMethods() + { + Assembly[] productionAssemblies = + [ + typeof(LexicalPath).Assembly, + typeof(SensitiveDataRedactingTextFormatter).Assembly, + typeof(LauncherApplicationHost).Assembly + ]; + + return productionAssemblies + .SelectMany(assembly => assembly.GetTypes()) + .Where(type => !IsCompilerGenerated(type)) + .SelectMany(type => type + .GetMethods( + BindingFlags.Public | + BindingFlags.NonPublic | + BindingFlags.Instance | + BindingFlags.Static | + BindingFlags.DeclaredOnly) + .Where(method => ReturnsAwaitable(method.ReturnType)) + .Where(method => !IsCompilerGenerated(method)) + // Property accessors, event add/remove, and operators cannot carry a suffix. + .Where(method => !method.IsSpecialName) + .Select(method => $"{type.FullName}.{method.Name}")) + .Order(StringComparer.Ordinal) + .ToList(); + } + + private static bool ReturnsAwaitable(Type returnType) + { + if (returnType == typeof(Task) || returnType == typeof(ValueTask)) + { + return true; + } + + if (!returnType.IsGenericType) + { + return false; + } + + Type openReturnType = returnType.GetGenericTypeDefinition(); + return openReturnType == typeof(Task<>) || openReturnType == typeof(ValueTask<>); + } + + private static bool IsCompilerGenerated(MemberInfo member) + { + // Async state machines, iterators, and local functions are emitted as members whose names + // the author never chose, so they are outside the convention. + return member.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false) || + member.Name.Contains('<', StringComparison.Ordinal); + } +} diff --git a/GenLauncherGO.Tests/Conventions/PublicTypeConsumptionTests.cs b/GenLauncherGO.Tests/Conventions/PublicTypeConsumptionTests.cs new file mode 100644 index 00000000..6a0de989 --- /dev/null +++ b/GenLauncherGO.Tests/Conventions/PublicTypeConsumptionTests.cs @@ -0,0 +1,129 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Reflection.Metadata; +using System.Reflection.PortableExecutable; +using System.Runtime.CompilerServices; +using GenLauncherGO.Core.IO; +using GenLauncherGO.Infrastructure.Logging; +using GenLauncherGO.UI.Features.Startup; + +namespace GenLauncherGO.Tests.Conventions; + +/// +/// Holds public visibility to types another production project actually consumes. +/// +/// +/// Consumption is read from the consuming assembly's TypeRef table rather than from its public +/// signatures. A type used only as a local inside a method body appears in the former and in +/// none of the latter, so signature scanning would report live types as unused — and a gate +/// that accuses wrongly teaches people to add exclusions, which is the drift being prevented. +/// The test suite is deliberately not counted as a consumer: every production assembly grants +/// it InternalsVisibleTo, so needing it from a test is not a reason to be public. +/// +public sealed class PublicTypeConsumptionTests +{ + [Fact] + public void EveryPublicCoreType_IsConsumedByInfrastructureOrUi() + { + HashSet consumed = ReadReferencedTypeNames(InfrastructureAssembly); + consumed.UnionWith(ReadReferencedTypeNames(UiAssembly)); + + AssertNoUnconsumedPublicTypes(CoreAssembly, consumed, "Infrastructure or UI"); + } + + [Fact] + public void EveryPublicInfrastructureType_IsConsumedByUi() + { + AssertNoUnconsumedPublicTypes( + InfrastructureAssembly, + ReadReferencedTypeNames(UiAssembly), + "UI"); + } + + [Fact] + public void ConsumptionAnalysis_ReadsBothSidesOfTheBoundary() + { + // A metadata read that silently returned nothing would let both tests above pass while + // checking nothing at all, which is the failure a convention test has to rule out + // explicitly rather than assume. + CoreAssembly.GetExportedTypes().Should().NotBeEmpty("Core must expose types to analyse"); + InfrastructureAssembly.GetExportedTypes().Should().NotBeEmpty( + "Infrastructure must expose types to analyse"); + ReadReferencedTypeNames(InfrastructureAssembly).Should().NotBeEmpty( + "Infrastructure must record the types it references"); + ReadReferencedTypeNames(UiAssembly).Should().NotBeEmpty( + "UI must record the types it references"); + } + + private static Assembly CoreAssembly => typeof(LexicalPath).Assembly; + + private static Assembly InfrastructureAssembly => typeof(SensitiveDataRedactingTextFormatter).Assembly; + + private static Assembly UiAssembly => typeof(LauncherApplicationHost).Assembly; + + private static void AssertNoUnconsumedPublicTypes( + Assembly producer, + HashSet consumedTypeNames, + string consumerDescription) + { + List offenders = producer.GetExportedTypes() + .Where(type => !type.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false)) + // A nested type is referenced through its declaring type, so the outermost type is what + // a consumer's metadata records. + .Select(GetOutermostType) + .Select(type => type.FullName) + .Where(name => name != null) + .Distinct(StringComparer.Ordinal) + .Where(name => !consumedTypeNames.Contains(name!)) + .Order(StringComparer.Ordinal) + .ToList()!; + + offenders.Should().BeEmpty( + "a type is public only when another production project consumes it, but {0} exposes {1} " + + "that {2} never references:{3}{4}", + producer.GetName().Name, + offenders.Count, + consumerDescription, + Environment.NewLine, + string.Join(Environment.NewLine, offenders)); + } + + private static Type GetOutermostType(Type type) + { + Type outermost = type; + while (outermost.DeclaringType != null) + { + outermost = outermost.DeclaringType; + } + + return outermost; + } + + /// + /// Reads every type the assembly references, including types touched only inside method bodies. + /// + private static HashSet ReadReferencedTypeNames(Assembly assembly) + { + var referencedTypeNames = new HashSet(StringComparer.Ordinal); + + using FileStream stream = File.OpenRead(assembly.Location); + using PEReader peReader = new(stream); + MetadataReader metadata = peReader.GetMetadataReader(); + + foreach (TypeReferenceHandle handle in metadata.TypeReferences) + { + TypeReference typeReference = metadata.GetTypeReference(handle); + string namespaceName = metadata.GetString(typeReference.Namespace); + string typeName = metadata.GetString(typeReference.Name); + + referencedTypeNames.Add(string.IsNullOrEmpty(namespaceName) + ? typeName + : $"{namespaceName}.{typeName}"); + } + + return referencedTypeNames; + } +} diff --git a/GenLauncherGO.Tests/Conventions/TestMethodNameConventionTests.cs b/GenLauncherGO.Tests/Conventions/TestMethodNameConventionTests.cs new file mode 100644 index 00000000..3f33c9aa --- /dev/null +++ b/GenLauncherGO.Tests/Conventions/TestMethodNameConventionTests.cs @@ -0,0 +1,26 @@ +using GenLauncherGO.TestAnalyzers; + +namespace GenLauncherGO.Tests.Conventions; + +public sealed class TestMethodNameConventionTests +{ + [Theory] + [InlineData("Member_ExpectedOutcome")] + [InlineData("Member_Scenario_ExpectedOutcome")] + [InlineData("Http2_UsesVersion2")] + public void IsValid_WithBehaviorOrScenarioName_ReturnsTrue(string name) + { + TestMethodNameConvention.IsValid(name).Should().BeTrue(); + } + + [Theory] + [InlineData("Member")] + [InlineData("member_ExpectedOutcome")] + [InlineData("Member_expectedOutcome")] + [InlineData("Member__ExpectedOutcome")] + [InlineData("Member_Scenario_ExpectedOutcome_Extra")] + public void IsValid_WithMalformedName_ReturnsFalse(string name) + { + TestMethodNameConvention.IsValid(name).Should().BeFalse(); + } +} diff --git a/GenLauncherGO.Tests/Core/IO/LexicalPathTests.cs b/GenLauncherGO.Tests/Core/IO/LexicalPathTests.cs new file mode 100644 index 00000000..de68fe3c --- /dev/null +++ b/GenLauncherGO.Tests/Core/IO/LexicalPathTests.cs @@ -0,0 +1,176 @@ +using System; +using System.IO; +using GenLauncherGO.Core.IO; + +namespace GenLauncherGO.Tests.Core.IO; + +public sealed class LexicalPathTests +{ + [Fact] + public void AreEquivalent_NormalizesCaseTrailingSeparatorsAndDotSegments() + { + using TestDirectory directory = new(); + string equivalentPath = Path.Combine(directory.Path.ToUpperInvariant(), ".", "Child", ".."); + + bool result = LexicalPath.AreEquivalent( + directory.Path.ToLowerInvariant() + Path.DirectorySeparatorChar, + equivalentPath); + + result.Should().BeTrue(); + } + + [Theory] + [InlineData(null, @"C:\Games\ZeroHour")] + [InlineData(@"C:\Games\ZeroHour", null)] + [InlineData("", @"C:\Games\ZeroHour")] + [InlineData(" ", @"C:\Games\ZeroHour")] + [InlineData(@"C:\Games\Generals", @"C:\Games\ZeroHour")] + public void AreEquivalent_WhenEitherPathIsMissingOrDifferent_ReturnsFalse( + string? left, + string? right) + { + LexicalPath.AreEquivalent(left, right).Should().BeFalse(); + } + + [Fact] + public void AreEquivalent_WhenAPathIsMalformed_ReturnsFalse() + { + const string MalformedPath = "invalid\0path"; + + bool result = LexicalPath.AreEquivalent(MalformedPath, MalformedPath); + + result.Should().BeFalse(); + } + + [Fact] + public void Containment_AcceptsRootAndChildWithWindowsCaseSemantics() + { + using TestDirectory directory = new(); + string childPath = Path.Combine(directory.Path, "Child", "file.txt"); + + LexicalPath.IsPathInDirectory(directory.Path.ToUpperInvariant(), directory.Path.ToLowerInvariant()) + .Should().BeTrue(); + LexicalPath.IsPathInDirectory(childPath.ToUpperInvariant(), directory.Path.ToLowerInvariant()) + .Should().BeTrue(); + } + + [Fact] + public void Containment_RejectsSiblingWithMatchingPrefix() + { + using TestDirectory directory = new(); + string siblingPath = directory.Path + "-sibling"; + + bool result = LexicalPath.IsPathInDirectory(siblingPath, directory.Path); + + result.Should().BeFalse(); + } + + [Fact] + public void RelativePaths_SeparatorsAndParentSegments_AreCanonicalAndDistinct() + { + using TestDirectory directory = new(); + string childPath = Path.Combine(directory.Path, "Data", "INI", "GameData.ini"); + string outsidePath = Path.Combine(directory.Path, "..", "Outside", "file.txt"); + + LexicalPath.GetRelativePath(directory.Path, childPath).Should().Be("Data/INI/GameData.ini"); + LexicalPath.RelativePathLeavesRoot(LexicalPath.GetRelativePath(directory.Path, outsidePath)) + .Should().BeTrue(); + LexicalPath.RelativePathLeavesRoot("../Outside/file.txt").Should().BeTrue(); + LexicalPath.RelativePathLeavesRoot("..cache/file.txt").Should().BeFalse(); + } + + [Fact] + public void ResolvePath_NormalizesTraversalWithoutClaimingContainment() + { + using TestDirectory directory = new(); + string resolvedPath = LexicalPath.ResolvePath(directory.Path, "../Outside/file.txt"); + + resolvedPath.Should().Be(Path.GetFullPath(Path.Combine(directory.Path, "..", "Outside", "file.txt"))); + LexicalPath.IsPathInDirectory(resolvedPath, directory.Path).Should().BeFalse(); + } + + [Fact] + public void ResolveContainedPath_AcceptsAChildPath() + { + using TestDirectory directory = new(); + + string result = LexicalPath.ResolveContainedPath( + directory.Path, + "Child/file.txt", + "The path must remain contained."); + + result.Should().Be(Path.Combine(directory.Path, "Child", "file.txt")); + } + + [Fact] + public void ResolveContainedPath_RejectsTraversal() + { + using TestDirectory directory = new(); + + Action act = () => LexicalPath.ResolveContainedPath( + directory.Path, + "../Outside/file.txt", + "The path must remain contained."); + + act.Should().Throw(); + } + + [Fact] + public void NormalizeRelativePath_UsesSlashSeparatorsWithoutOuterSlashes() + { + string result = LexicalPath.NormalizeRelativePath(@"\Data\INI\GameData.ini/"); + + result.Should().Be("Data/INI/GameData.ini"); + } + + [Fact] + public void Containment_AcceptsChildOfADriveRoot() + { + using TestDirectory directory = new(); + + LexicalPath.IsPathInDirectory(@"C:\Games\ZeroHour", @"C:\").Should().BeTrue(); + LexicalPath.IsPathInDirectory(directory.Path, directory.Path + Path.DirectorySeparatorChar) + .Should().BeTrue(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(@"C:\Escape")] + [InlineData("file:stream")] + [InlineData("na|me")] + [InlineData(" LexicalPath.NormalizePathSegment(segment, nameof(segment)); + + act.Should().Throw() + .WithParameterName(nameof(segment)); + } + + [Theory] + [InlineData("ABC1")] + [InlineData("COM0")] + [InlineData("COM12")] + [InlineData("COMX")] + [InlineData("LPT0")] + [InlineData("LPT10")] + [InlineData("LPTX")] + public void NormalizePathSegment_AcceptsNamesThatOnlyResembleReservedDevices(string segment) + { + string result = LexicalPath.NormalizePathSegment(segment, nameof(segment)); + + result.Should().Be(segment); + } +} diff --git a/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityReportTests.cs b/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityReportTests.cs new file mode 100644 index 00000000..364c19f2 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityReportTests.cs @@ -0,0 +1,80 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Tests.Core.Integrity.Models; + +public sealed class ContentIntegrityReportTests +{ + [Fact] + public void ConstructorDefensively_CopiesIssues() + { + List issues = + [ + new ContentIntegrityIssue( + "target", + "Target", + ContentSourceKind.ManagedS3, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.Repair, + "file.bin") + ]; + + ContentIntegrityReport report = new(issues); + issues.Clear(); + + report.Issues.Should().ContainSingle(); + } + + [Fact] + public void IssueFlags_ReflectActionableIssueKinds() + { + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + "legacy", + "Legacy", + ContentSourceKind.UnknownLegacy, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.TrustAsManual, + "legacy.big"), + new ContentIntegrityIssue( + "blocking", + "Blocking", + ContentSourceKind.UnknownLegacy, + IntegrityIssueKind.VerificationError, + IntegrityIssueAction.Block, + ".") + }); + + report.HasIssues.Should().BeTrue(); + report.HasUnknownLegacyIssues.Should().BeTrue(); + report.HasBlockingIssues.Should().BeTrue(); + } + + [Fact] + public void IssueFlags_WithOnlyRepairableIssues_ReportNeitherLegacyNorBlocking() + { + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + "repairable", + "Repairable", + ContentSourceKind.ManagedS3, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.Repair, + "file.bin") + }); + + report.HasIssues.Should().BeTrue(); + report.HasUnknownLegacyIssues.Should().BeFalse(); + report.HasBlockingIssues.Should().BeFalse(); + } + + [Fact] + public void IssueFlags_WithoutIssues_ReportNoIssues() + { + ContentIntegrityReport report = new([]); + + report.HasIssues.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..d857c2a2 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Integrity/Models/ContentIntegrityTargetTests.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Tests.Core.Integrity.Models; + +public sealed class ContentIntegrityTargetTests +{ + [Fact] + public void ConstructorDefensively_CopiesIgnoredPaths() + { + HashSet ignoredPaths = new(StringComparer.OrdinalIgnoreCase) + { + "inactive.png" + }; + + ContentIntegrityTarget target = new( + "target", + "Target", + "content", + ContentSourceKind.ManagedS3, + ignoredPaths); + ignoredPaths.Clear(); + + target.IgnoredRelativePaths.Should().Contain("inactive.png"); + } + + [Fact] + public void Constructor_IgnoredPaths_CanonicalizesWithWindowsSemantics() + { + ContentIntegrityTarget target = new( + "target", + "Target", + "content", + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.Ordinal) { @"\Inactive\FILE.PNG/" }); + + target.IgnoredRelativePaths.Contains("inactive/file.png").Should().BeTrue(); + } +} diff --git a/GenLauncherGO.Tests/Core/Integrity/Models/ContentSourceKindTests.cs b/GenLauncherGO.Tests/Core/Integrity/Models/ContentSourceKindTests.cs new file mode 100644 index 00000000..4f46ba0a --- /dev/null +++ b/GenLauncherGO.Tests/Core/Integrity/Models/ContentSourceKindTests.cs @@ -0,0 +1,20 @@ +using GenLauncherGO.Core.Integrity.Models; + +namespace GenLauncherGO.Tests.Core.Integrity.Models; + +public sealed class ContentSourceKindTests +{ + [Theory] + [InlineData(ContentSourceKind.UnknownLegacy, false)] + [InlineData(ContentSourceKind.ManagedS3, true)] + [InlineData(ContentSourceKind.ManagedSingleFile, true)] + [InlineData(ContentSourceKind.Manual, false)] + public void IsManagedRemote_ClassifiesRestorableSources( + ContentSourceKind sourceKind, + bool expected) + { + bool isManagedRemote = sourceKind.IsManagedRemote(); + + isManagedRemote.Should().Be(expected); + } +} diff --git a/GenLauncherGO.Tests/Core/Launching/LauncherGameArgumentServiceTests.cs b/GenLauncherGO.Tests/Core/Launching/LauncherGameArgumentServiceTests.cs new file mode 100644 index 00000000..89cee3a2 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Launching/LauncherGameArgumentServiceTests.cs @@ -0,0 +1,119 @@ +using GenLauncherGO.Core.Launching; + +namespace GenLauncherGO.Tests.Core.Launching; + +public sealed class LauncherGameArgumentServiceTests +{ + [Fact] + public void SetArgumentEnabled_AddsArgumentWhenMissing() + { + string arguments = "-foo"; + + string result = LauncherGameArgumentService.SetArgumentEnabled( + arguments, + LauncherGameArgumentService.WindowedArgument, + true); + + result.Should().Be("-foo -win"); + } + + [Fact] + public void SetArgumentEnabled_DoesNotDuplicateExistingArgument() + { + string arguments = "-foo -WIN"; + + string result = LauncherGameArgumentService.SetArgumentEnabled( + arguments, + LauncherGameArgumentService.WindowedArgument, + true); + + result.Should().Be("-foo -WIN"); + } + + [Fact] + public void SetArgumentEnabled_RemovesStandaloneArgumentAndKeepsOtherArguments() + { + string arguments = "-foo \"bar baz\" -win -quickstart"; + + string result = LauncherGameArgumentService.SetArgumentEnabled( + arguments, + LauncherGameArgumentService.WindowedArgument, + false); + + result.Should().Be("-foo \"bar baz\" -quickstart"); + } + + [Theory] + [InlineData(null, "")] + [InlineData("", "")] + [InlineData(" ", "")] + [InlineData(" -foo -win ", "-foo")] + public void SetArgumentEnabled_WhenDisablingArgument_NormalizesSurroundingWhitespace( + string? arguments, + string expected) + { + string result = LauncherGameArgumentService.SetArgumentEnabled( + arguments, + LauncherGameArgumentService.WindowedArgument, + false); + + result.Should().Be(expected); + } + + [Fact] + public void SetArgumentEnabled_RemovesCompleteQuotedArgument() + { + string result = LauncherGameArgumentService.SetArgumentEnabled( + "\"-win\" -quickstart", + LauncherGameArgumentService.WindowedArgument, + false); + + result.Should().Be("-quickstart"); + } + + [Fact] + public void ContainsArgument_RequiresStandaloneArgument() + { + string arguments = "-windowed -quickstart"; + + bool containsWindowed = LauncherGameArgumentService.ContainsArgument( + arguments, + LauncherGameArgumentService.WindowedArgument); + bool containsQuickStart = LauncherGameArgumentService.ContainsArgument( + arguments, + LauncherGameArgumentService.QuickStartArgument); + + containsWindowed.Should().BeFalse(); + containsQuickStart.Should().BeTrue(); + } + + [Fact] + public void ContainsArgument_RecognizesCompleteQuotedArgument() + { + bool result = LauncherGameArgumentService.ContainsArgument( + "\"-win\" -quickstart", + LauncherGameArgumentService.WindowedArgument); + + result.Should().BeTrue(); + } + + [Fact] + public void ContainsArgument_DoesNotUnquoteIncompleteQuotedToken() + { + bool result = LauncherGameArgumentService.ContainsArgument( + "\"-win", + LauncherGameArgumentService.WindowedArgument); + + result.Should().BeFalse(); + } + + [Fact] + public void ContainsArgument_DoesNotTreatTrailingQuoteAsAnOpeningQuote() + { + bool result = LauncherGameArgumentService.ContainsArgument( + "--win\"", + LauncherGameArgumentService.WindowedArgument); + + result.Should().BeFalse(); + } +} diff --git a/GenLauncherGO.Tests/Core/Launching/Models/LaunchContentIntegrityResolutionProgressTests.cs b/GenLauncherGO.Tests/Core/Launching/Models/LaunchContentIntegrityResolutionProgressTests.cs new file mode 100644 index 00000000..91720ef5 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Launching/Models/LaunchContentIntegrityResolutionProgressTests.cs @@ -0,0 +1,29 @@ +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Tests.Core.Launching.Models; + +public sealed class LaunchContentIntegrityResolutionProgressTests +{ + [Fact] + public void Package_HasProgressAndIsNotComplete() + { + PackageUpdateProgress packageProgress = new(null, 10, null, "package.zip"); + + var progress = + LaunchContentIntegrityResolutionProgress.Package("target", packageProgress); + + progress.PackageProgress.Should().BeSameAs(packageProgress); + progress.Completed.Should().BeFalse(); + } + + [Fact] + public void Complete_HasNoProgressAndIsComplete() + { + var progress = + LaunchContentIntegrityResolutionProgress.Complete("target"); + + progress.PackageProgress.Should().BeNull(); + progress.Completed.Should().BeTrue(); + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentFileTypesTests.cs b/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentFileTypesTests.cs new file mode 100644 index 00000000..256d7c57 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentFileTypesTests.cs @@ -0,0 +1,117 @@ +using System.Collections; +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Core.Mods.Models; + +/// +/// Pins the single authority for which files the launcher treats as content. +/// +/// +/// Manual import, single-file package updates, the artwork cache, and the pickers a user chooses files with all +/// read these sets. Widening one silently makes the importer extract a file it should have copied; narrowing one +/// leaves a downloaded archive unextracted or an offered file rejected after the user picked it. +/// +public sealed class LauncherContentFileTypesTests +{ + [Theory] + [InlineData("mod.zip")] + [InlineData("mod.rar")] + [InlineData("mod.7z")] + [InlineData("MOD.ZIP")] + [InlineData("MOD.Rar")] + [InlineData("MOD.7Z")] + [InlineData(@"C:\downloads\nested.folder\mod.zip")] + public void IsArchive_WithArchiveExtension_ReturnsTrue(string filePath) + { + LauncherContentFileTypes.IsArchive(filePath).Should().BeTrue(); + } + + [Theory] + [InlineData("asset.big")] + [InlineData("asset.gib")] + [InlineData("readme.txt")] + [InlineData("installer.exe")] + [InlineData("mod.tar")] + [InlineData("mod.gz")] + [InlineData("mod.zipx")] + [InlineData("mod.z")] + [InlineData("zip")] + [InlineData("mod")] + [InlineData("")] + public void IsArchive_WithoutArchiveExtension_ReturnsFalse(string filePath) + { + LauncherContentFileTypes.IsArchive(filePath).Should().BeFalse(); + } + + [Theory] + [InlineData(".png")] + [InlineData(".jpg")] + [InlineData(".jpeg")] + [InlineData(".PNG")] + [InlineData(".JPeG")] + public void IsImage_WithAcceptedArtworkExtension_ReturnsTrue(string extension) + { + LauncherContentFileTypes.IsImage(extension).Should().BeTrue(); + } + + [Theory] + [InlineData(".bmp")] + [InlineData(".gif")] + [InlineData(".webp")] + [InlineData(".png.exe")] + [InlineData("png")] + [InlineData("")] + public void IsImage_WithOtherExtension_ReturnsFalse(string extension) + { + LauncherContentFileTypes.IsImage(extension).Should().BeFalse(); + } + + /// + /// Spells the sets out once. Every other caller asks these properties instead of listing extensions, which is + /// what keeps them consistent but also leaves this the only place a changed set can be noticed — a test that + /// built its expectation from the same properties would move with the change and see nothing. + /// + [Fact] + public void AcceptedFormats_PinTheSetsEveryLayerReadsFromHere() + { + LauncherContentFileTypes.ArchiveExtensions.Should().Equal(".zip", ".rar", ".7z"); + LauncherContentFileTypes.GamePackageExtensions.Should().Equal(".big", ".gib"); + LauncherContentFileTypes.ImageExtensions.Should().Equal(".png", ".jpg", ".jpeg"); + LauncherContentFileTypes.DefaultImageExtension.Should().Be(".png"); + } + + /// + /// A game package is copied into place, not unpacked. If the two sets ever overlapped, manual import would + /// try to extract a .big the game is meant to read directly. + /// + [Fact] + public void GamePackagesAndArchives_DoNotOverlap() + { + LauncherContentFileTypes.GamePackageExtensions.Should() + .NotIntersectWith(LauncherContentFileTypes.ArchiveExtensions); + } + + [Fact] + public void AcceptedFormats_CannotBeChangedByConsumers() + { + AssertReadOnly(LauncherContentFileTypes.ArchiveExtensions); + AssertReadOnly(LauncherContentFileTypes.GamePackageExtensions); + AssertReadOnly(LauncherContentFileTypes.ImageExtensions); + } + + private static void AssertReadOnly(IReadOnlyList extensions) + { + extensions.Should().NotBeAssignableTo(); + + if (extensions is ICollection genericCollection) + { + genericCollection.IsReadOnly.Should().BeTrue(); + } + + if (extensions is IList collection) + { + collection.IsReadOnly.Should().BeTrue(); + } + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentKeyTests.cs b/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentKeyTests.cs new file mode 100644 index 00000000..f37017f9 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentKeyTests.cs @@ -0,0 +1,119 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Core.Mods.Models; + +public sealed class LauncherContentKeyTests +{ + [Fact] + public void VersionIdentity_DeduplicatesCaseInsensitiveMatchesInHashCollections() + { + LauncherContentVersion first = TestLauncherContent.Version( + "ShockWave", + "1.2", + ModificationType.Addon, + "Parent"); + LauncherContentVersion duplicate = TestLauncherContent.Version( + "shockwave", + "1.2", + ModificationType.Addon, + "parent"); + var keys = new HashSet + { + first.ContentKey, + duplicate.ContentKey + }; + + keys.Should().ContainSingle().Which.Should().Be(first.ContentKey); + } + + /// + /// Every identity component has to reach both the comparison and the hash. A component the hash leaves out + /// still compares correctly, so equality alone cannot see the omission — while every catalog key that differs + /// only in that component starts landing in one bucket. + /// + [Fact] + public void VersionIdentity_IncludesVersionTypeAndParent() + { + LauncherContentKey key = TestLauncherContent + .Version("Shared", "1.0", ModificationType.Addon, "First").ContentKey; + LauncherContentKey otherName = TestLauncherContent + .Version("Other", "1.0", ModificationType.Addon, "First").ContentKey; + LauncherContentKey otherVersion = TestLauncherContent + .Version("Shared", "2.0", ModificationType.Addon, "First").ContentKey; + LauncherContentKey otherType = TestLauncherContent + .Version("Shared", "1.0", ModificationType.Patch, "First").ContentKey; + LauncherContentKey otherParent = TestLauncherContent + .Version("Shared", "1.0", ModificationType.Addon, "Second").ContentKey; + + key.Should().NotBe(otherName); + key.Should().NotBe(otherVersion); + key.Should().NotBe(otherType); + key.Should().NotBe(otherParent); + key.GetHashCode().Should().NotBe(otherName.GetHashCode()); + key.GetHashCode().Should().NotBe(otherVersion.GetHashCode()); + key.GetHashCode().Should().NotBe(otherType.GetHashCode()); + key.GetHashCode().Should().NotBe(otherParent.GetHashCode()); + } + + [Fact] + public void MissingIdentityText_RetainsEmptyStringComparisonSemantics() + { + var missingText = new LauncherContentKey(ModificationType.Mod, null, null, null); + var emptyText = new LauncherContentKey( + ModificationType.Mod, + string.Empty, + string.Empty, + string.Empty); + LauncherContentKey defaultKey = default; + + missingText.Should().Be(emptyText); + defaultKey.Should().Be(emptyText); + defaultKey.GetHashCode().Should().Be(emptyText.GetHashCode()); + missingText.ParentIdentity.Should().BeEmpty(); + missingText.Name.Should().BeEmpty(); + missingText.Version.Should().BeEmpty(); + missingText.HasName(null).Should().BeTrue(); + LauncherContentKey.OriginalGame.HasName("original game").Should().BeTrue(); + } + + [Fact] + public void OriginalGameIdentity_IsStableAndMatchesLegacyCasing() + { + LauncherContentKey originalGame = LauncherContentKey.OriginalGame; + var originalGamePatch = new LauncherContentKey( + ModificationType.Patch, + "Original game", + "GenPatcher", + "1.0"); + + originalGame.ContentType.Should().Be(ModificationType.Mod); + originalGame.ParentIdentity.Should().BeEmpty(); + originalGame.Name.Should().Be("Original Game"); + originalGame.Version.Should().BeEmpty(); + originalGamePatch.IsChildOf(originalGame).Should().BeTrue(); + } + + [Fact] + public void StableString_PreservesExistingIntegrityIdentityFormat() + { + var key = new LauncherContentKey( + ModificationType.Addon, + "ShockWave Patch", + "Music Pack", + "V1.2"); + + key.ToStableString().Should().Be("addon:shockwave patch:music pack:v1.2"); + } + + [Fact] + public void ModificationNameIdentity_SetsOnlyTheModificationName() + { + var key = LauncherContentKey.ForModificationName("ShockWave"); + + key.ContentType.Should().Be(ModificationType.Mod); + key.ParentIdentity.Should().BeEmpty(); + key.Name.Should().Be("ShockWave"); + key.Version.Should().BeEmpty(); + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentTests.cs b/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentTests.cs new file mode 100644 index 00000000..d80c802f --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentTests.cs @@ -0,0 +1,302 @@ +using System; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Core.Mods.Models; + +public sealed class LauncherContentTests +{ + [Fact] + public void AddOrUpdate_KeepsOneCanonicalVersionAndCombinesLocalStateWithRemoteMetadata() + { + var localState = new LauncherContentInstallation + { + Installed = true, + ContentSourceKind = ContentSourceKind.UnknownLegacy + }; + var localVersion = new LauncherContentVersion(localState) + { + Name = "ShockWave", + Version = "1.2", + ModificationType = ModificationType.Mod, + SimpleDownloadLink = "https://example.test/local-package.zip", + ModDBLink = "https://example.test/local-moddb", + S3BucketName = "local-mods" + }; + var remoteState = new LauncherContentInstallation + { + IsSelected = true, + ContentSourceKind = ContentSourceKind.ManagedSingleFile + }; + var remoteVersion = new LauncherContentVersion(remoteState) + { + Name = "shockwave", + Version = "1.2", + ModificationType = ModificationType.Mod, + SimpleDownloadLink = "https://example.test/package.zip", + ModDBLink = "https://example.test/moddb", + S3BucketName = "remote-mods" + }; + + LauncherContent content = TestLauncherContent.From(localVersion, remoteVersion); + + LauncherContentVersion merged = content.Versions.Should().ContainSingle().Which; + merged.ContentKey.Should().Be(localVersion.ContentKey); + merged.Name.Should().Be("ShockWave"); + merged.SimpleDownloadLink.Should().Be("https://example.test/local-package.zip"); + merged.ModDBLink.Should().Be("https://example.test/local-moddb"); + merged.S3BucketName.Should().Be("local-mods"); + merged.Installation.Should().BeSameAs(localState); + merged.Installation.Installed.Should().BeTrue(); + merged.Installation.IsSelected.Should().BeTrue(); + merged.EffectiveContentSourceKind.Should().Be(ContentSourceKind.ManagedSingleFile); + content.IsSelected.Should().BeTrue(); + content.Installed.Should().BeTrue(); + } + + [Fact] + public void AddOrUpdate_WhenLocalStateIsUnclassified_AdoptsTheIncomingSourceKind() + { + LauncherContentVersion localVersion = TestLauncherContent.Version( + version: "1.2", + installed: true, + sourceKind: ContentSourceKind.UnknownLegacy); + LauncherContentVersion remoteVersion = TestLauncherContent.Version( + version: "1.2", + sourceKind: ContentSourceKind.Manual); + + LauncherContent content = TestLauncherContent.From(localVersion, remoteVersion); + + content.Versions.Should().ContainSingle() + .Which.EffectiveContentSourceKind.Should().Be(ContentSourceKind.Manual); + } + + [Fact] + public void AddOrUpdate_WhenLocalStateIsAlreadyClassified_KeepsItOverAnUnclassifiedIncoming() + { + LauncherContentVersion localVersion = TestLauncherContent.Version( + version: "1.2", + installed: true, + sourceKind: ContentSourceKind.Manual); + LauncherContentVersion remoteVersion = TestLauncherContent.Version( + version: "1.2", + sourceKind: ContentSourceKind.UnknownLegacy); + + LauncherContent content = TestLauncherContent.From(localVersion, remoteVersion); + + content.Versions.Should().ContainSingle() + .Which.EffectiveContentSourceKind.Should().Be(ContentSourceKind.Manual); + } + + [Fact] + public void AddOrUpdate_WhenBothStatesAreClassified_KeepsTheLocalClassification() + { + LauncherContentVersion localVersion = TestLauncherContent.Version( + version: "1.2", + installed: true, + sourceKind: ContentSourceKind.Manual); + LauncherContentVersion remoteVersion = TestLauncherContent.Version( + version: "1.2", + sourceKind: ContentSourceKind.ManagedSingleFile); + + LauncherContent content = TestLauncherContent.From(localVersion, remoteVersion); + + content.Versions.Should().ContainSingle() + .Which.EffectiveContentSourceKind.Should().Be(ContentSourceKind.Manual); + } + + [Fact] + public void AddOrUpdate_WhenLocalRecordHasNoCatalogMetadata_PreservesEveryIncomingFieldAndLocalState() + { + var localInstallation = new LauncherContentInstallation + { + Installed = true, + ContentSourceKind = ContentSourceKind.UnknownLegacy + }; + var localVersion = new LauncherContentVersion(localInstallation) + { + ModificationType = ModificationType.Patch, + Name = "Patch", + Version = "2.0", + ParentContentName = "ShockWave" + }; + var theme = new LauncherContentTheme { GenLauncherActiveColor = "#FF123456" }; + var incomingVersion = new LauncherContentVersion + { + ModificationType = ModificationType.Patch, + Name = "Patch", + Version = "2.0", + ParentContentName = "ShockWave", + SimpleDownloadLink = "https://example.test/package.zip", + UIImageSourceLink = "https://example.test/image.png", + DiscordLink = "https://example.test/discord", + ModDBLink = "https://example.test/moddb", + NewsLink = "https://example.test/news", + S3HostLink = "https://s3.example.test", + S3BucketName = "packages", + S3FolderName = "shockwave/patch/2.0", + S3HostPublicKey = "public-key", + S3HostSecretKey = "secret-key", + NetworkInfo = "Community service required.", + Deprecated = true, + SupportLink = "https://example.test/support", + Theme = theme + }; + + LauncherContent content = TestLauncherContent.From(localVersion, incomingVersion); + + LauncherContentVersion merged = content.Versions.Should().ContainSingle().Which; + merged.Should().BeEquivalentTo( + incomingVersion, + options => options.Excluding(version => version.Installation)); + merged.Installation.Should().BeSameAs(localInstallation); + merged.Installation.Installed.Should().BeTrue(); + merged.EffectiveContentSourceKind.Should().Be(ContentSourceKind.ManagedS3); + } + + [Fact] + public void AddOrUpdate_WhenRemoteMetadataRefreshesExistingVersion_KeepsPublishedTheme() + { + LauncherContentVersion localVersion = TestLauncherContent.Version(version: "1.2", installed: true); + var publishedTheme = new LauncherContentTheme { GenLauncherActiveColor = "#FF123456" }; + LauncherContentVersion remoteVersion = TestLauncherContent.Version(version: "1.2", theme: publishedTheme); + + LauncherContent content = TestLauncherContent.From(localVersion, remoteVersion); + + content.Versions.Should().ContainSingle().Which.Theme.Should().BeSameAs(publishedTheme); + } + + [Fact] + public void AddOrUpdate_WhenRemoteMetadataCarriesNoTheme_KeepsTheAlreadyPublishedTheme() + { + var publishedTheme = new LauncherContentTheme { GenLauncherActiveColor = "#FF123456" }; + LauncherContentVersion localVersion = TestLauncherContent.Version( + version: "1.2", + installed: true, + theme: publishedTheme); + LauncherContentVersion remoteVersion = TestLauncherContent.Version(version: "1.2"); + + LauncherContent content = TestLauncherContent.From(localVersion, remoteVersion); + + content.Versions.Should().ContainSingle().Which.Theme.Should().BeSameAs(publishedTheme); + } + + [Fact] + public void AddOrUpdate_WhenBothVersionsPublishATheme_KeepsTheRemoteTheme() + { + var localTheme = new LauncherContentTheme { GenLauncherActiveColor = "#FF111111" }; + var remoteTheme = new LauncherContentTheme { GenLauncherActiveColor = "#FF222222" }; + LauncherContentVersion localVersion = TestLauncherContent.Version( + version: "1.2", + installed: true, + theme: localTheme); + LauncherContentVersion remoteVersion = TestLauncherContent.Version(version: "1.2", theme: remoteTheme); + + LauncherContent content = TestLauncherContent.From(localVersion, remoteVersion); + + content.Versions.Should().ContainSingle().Which.Theme.Should().BeSameAs(remoteTheme); + } + + [Fact] + public void AddOrMergeVersion_WithVersionFromAnotherCard_Throws() + { + LauncherContent content = TestLauncherContent.From(TestLauncherContent.Version(version: "1.2")); + LauncherContentVersion otherCardVersion = TestLauncherContent.Version("Rise Of The Reds", "1.2"); + + Action act = () => content.AddOrMergeVersion(otherCardVersion); + + act.Should().Throw() + .WithParameterName("version"); + } + + [Fact] + public void LatestVersion_IsTheCardPresentationMetadataAuthority() + { + LauncherContentVersion earliest = new() + { + Name = "ShockWave", + Version = "1.0", + ModificationType = ModificationType.Mod, + SupportLink = "https://example.test/old" + }; + LauncherContentVersion latest = new() + { + Name = "ShockWave", + Version = "2.0", + ModificationType = ModificationType.Mod, + SupportLink = "https://example.test/current" + }; + + LauncherContent content = TestLauncherContent.From(earliest, latest); + + content.LatestVersion.Should().BeSameAs(latest); + content.LatestVersion.SupportLink.Should().Be("https://example.test/current"); + } + + [Fact] + public void SelectedVersion_UsesPersistedInstalledSelectionBeforeFallbacks() + { + LauncherContentVersion earliestInstalled = TestLauncherContent.Version(version: "1.0", installed: true); + LauncherContentVersion selectedInstalled = TestLauncherContent.Version( + version: "2.0", + installed: true, + isSelected: true); + LauncherContentVersion latestRemote = TestLauncherContent.Version(version: "3.0", isSelected: true); + LauncherContent content = TestLauncherContent.From( + earliestInstalled, + latestRemote, + selectedInstalled); + + LauncherContentVersion? selectedVersion = content.GetSelectedVersion(); + + selectedVersion.Should().BeSameAs(selectedInstalled); + } + + [Fact] + public void SelectedVersion_WithoutASavedSelection_PrefersInstalledOverEarlierCatalogVersions() + { + LauncherContentVersion earliestCatalogVersion = TestLauncherContent.Version(version: "1.0"); + LauncherContentVersion earliestInstalled = TestLauncherContent.Version(version: "2.0", installed: true); + LauncherContentVersion laterInstalled = TestLauncherContent.Version(version: "3.0", installed: true); + LauncherContent content = TestLauncherContent.From( + earliestCatalogVersion, + earliestInstalled, + laterInstalled); + + LauncherContentVersion? selectedVersion = content.GetSelectedVersion(); + + selectedVersion.Should().BeSameAs(earliestInstalled); + } + + [Fact] + public void SelectedVersion_FallsBackToEarliestInstalledThenEarliestKnownVersion() + { + LauncherContentVersion latestRemote = TestLauncherContent.Version(version: "3.0"); + LauncherContentVersion earliestInstalled = TestLauncherContent.Version(version: "1.0", installed: true); + LauncherContentVersion laterInstalled = TestLauncherContent.Version(version: "2.5", installed: true); + LauncherContentVersion middleRemote = TestLauncherContent.Version(version: "2.0"); + LauncherContent installedContent = TestLauncherContent.From( + latestRemote, + earliestInstalled, + laterInstalled, + middleRemote); + LauncherContent remoteContent = TestLauncherContent.From(latestRemote, middleRemote); + + installedContent.GetSelectedVersion().Should().BeSameAs(earliestInstalled); + remoteContent.GetSelectedVersion().Should().BeSameAs(middleRemote); + } + + [Fact] + public void LatestInstalledVersion_UsesCanonicalVersionOrdering() + { + LauncherContentVersion latestInstalled = TestLauncherContent.Version(version: "2.0", installed: true); + LauncherContentVersion earliestInstalled = TestLauncherContent.Version(version: "1.0", installed: true); + LauncherContentVersion remoteUpdate = TestLauncherContent.Version(version: "3.0"); + LauncherContent content = TestLauncherContent.From( + latestInstalled, + remoteUpdate, + earliestInstalled); + + content.LatestInstalledVersion.Should().BeSameAs(latestInstalled); + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentThemeTests.cs b/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentThemeTests.cs new file mode 100644 index 00000000..f9e59d1c --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentThemeTests.cs @@ -0,0 +1,69 @@ +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Core.Mods.Models; + +public sealed class LauncherContentThemeTests +{ + [Fact] + public void ThemeSlots_ThatAModificationDidNotPublish_AreEmptyRatherThanNull() + { + LauncherContentTheme theme = new(); + + theme.GenLauncherBorderColor.Should().BeEmpty(); + theme.GenLauncherInactiveBorder.Should().BeEmpty(); + theme.GenLauncherInactiveBorder2.Should().BeEmpty(); + theme.GenLauncherActiveColor.Should().BeEmpty(); + theme.GenLauncherDarkFillColor.Should().BeEmpty(); + theme.GenLauncherDarkBackGround.Should().BeEmpty(); + theme.GenLauncherLightBackGround.Should().BeEmpty(); + theme.GenLauncherDefaultTextColor.Should().BeEmpty(); + theme.GenLauncherDownloadTextColor.Should().BeEmpty(); + theme.GenLauncherListBoxSelectionColor1.Should().BeEmpty(); + theme.GenLauncherListBoxSelectionColor2.Should().BeEmpty(); + theme.GenLauncherButtonSelectionColor.Should().BeEmpty(); + theme.GenLauncherBackgroundImageLink.Should().BeEmpty(); + } + + [Fact] + public void HasValues_WhenNoUsableValueWasPublished_ReturnsFalse() + { + LauncherContentTheme empty = new(); + var whitespace = new LauncherContentTheme { GenLauncherActiveColor = " " }; + + empty.HasValues.Should().BeFalse(); + whitespace.HasValues.Should().BeFalse(); + } + + [Fact] + public void HasValues_WhenPaletteOrArtworkWasPublished_ReturnsTrue() + { + var palette = new LauncherContentTheme { GenLauncherActiveColor = "#102030" }; + var artwork = new LauncherContentTheme + { + GenLauncherBackgroundImageLink = "https://cdn.example.test/background.png" + }; + + palette.HasValues.Should().BeTrue(); + artwork.HasValues.Should().BeTrue(); + } + + /// + /// Pins the cache names the whole launcher agrees on. Every other caller — the download cache, the tile + /// presenter, content removal, and integrity scanning — asks these methods instead of spelling the suffixes + /// out, which is what keeps them consistent but also what leaves this the only place a changed suffix can be + /// noticed. A test that built its expectation from the same call would move with the change and see nothing. + /// + [Fact] + public void CacheBaseNames_KeepArtworkAndPaletteApartFromTheTileImage() + { + const string Version = "1.2"; + + string backgroundBaseName = LauncherContentTheme.ResolveBackgroundImageBaseName(Version); + string paletteBaseName = LauncherContentTheme.ResolveCacheBaseName(Version); + + backgroundBaseName.Should().Be("1.2-background"); + paletteBaseName.Should().Be("1.2-theme"); + backgroundBaseName.Should().NotBe(Version, "the tile image is cached under the bare version"); + paletteBaseName.Should().NotBe(Version, "the tile image is cached under the bare version"); + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentVersionTests.cs b/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentVersionTests.cs new file mode 100644 index 00000000..0df2574e --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Models/LauncherContentVersionTests.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Core.Mods.Models; + +public sealed class LauncherContentVersionTests +{ + [Theory] + [InlineData("", "", 0)] + [InlineData("", "release", 0)] + [InlineData("release", "beta", 0)] + [InlineData("0", "release", 1)] + [InlineData("1", "2", -1)] + [InlineData("1.2", "1.20", 0)] + [InlineData("1.2-beta", "1.2", 0)] + [InlineData("1.2-rc1", "1.2-rc2", -1)] + [InlineData("009", "1.2", -1)] + public void VersionComparer_PreservesLegacyNumericProjection( + string left, + string right, + int expectedSign) + { + var leftVersion = new LauncherContentVersion { Version = left }; + var rightVersion = new LauncherContentVersion { Version = right }; + + int comparison = leftVersion.CompareTo(rightVersion); + + Math.Sign(comparison).Should().Be(expectedSign); + } + + /// + /// The comparer is declared as over a nullable string, so a missing label is + /// part of the contract it offers rather than an impossible input: it has to order against one the same way + /// it orders a label carrying no digits, instead of throwing part-way through sorting a card's versions. + /// + [Fact] + public void VersionComparer_WithAMissingLabel_OrdersItAsCarryingNoDigits() + { + IComparer comparer = LauncherContentVersionComparer.Instance; + + comparer.Compare(null, "1.2").Should().BeNegative(); + comparer.Compare("1.2", null).Should().BePositive(); + comparer.Compare(null, null).Should().Be(0); + comparer.Compare(null, "release").Should().Be(0); + } + + [Fact] + public void CompareTo_HandlesVeryLargeDigitSequencesWithoutOverflow() + { + var older = new LauncherContentVersion { Version = new string('8', 1_000) }; + var newer = new LauncherContentVersion { Version = new string('9', 1_000) }; + + int comparison = older.CompareTo(newer); + + comparison.Should().BeNegative(); + } + + [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) + { + ContentSourceKind sourceKind = LauncherContentVersion.ResolveContentSourceKind( + s3HostLink, + s3BucketName, + s3FolderName, + simpleDownloadLink, + fallbackSourceKind); + + sourceKind.Should().Be(expectedSourceKind); + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Models/LauncherDataTests.cs b/GenLauncherGO.Tests/Core/Mods/Models/LauncherDataTests.cs new file mode 100644 index 00000000..0a5ef028 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Models/LauncherDataTests.cs @@ -0,0 +1,364 @@ +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Core.Mods.Models; + +public sealed class LauncherDataTests +{ + [Fact] + public void AddOrUpdate_AddsContentToMatchingCollections() + { + LauncherData launcherData = new(); + + launcherData.AddOrUpdate(TestLauncherContent.Version("Shockwave", type: ModificationType.Mod)); + launcherData.AddOrUpdate(TestLauncherContent.Version("Patch", type: ModificationType.Patch)); + launcherData.AddOrUpdate(TestLauncherContent.Version( + "Addon", + type: ModificationType.Addon, + parentContentName: "Shockwave")); + launcherData.AddOrUpdate(TestLauncherContent.Version("Orphan Addon", type: ModificationType.Addon)); + + launcherData.Modifications.Select(modification => modification.Name) + .Should() + .ContainSingle() + .Which.Should().Be("Shockwave"); + launcherData.Patches.Should().ContainSingle().Which.Name.Should().Be("Patch"); + launcherData.Addons.Should().ContainSingle().Which.Name.Should().Be("Addon"); + } + + [Fact] + public void AddOrUpdate_MergesMatchingVersionsIntoExistingContentCard() + { + LauncherData launcherData = new(); + LauncherContentVersion installedVersion = TestLauncherContent.Version("Shockwave", installed: true); + LauncherContentVersion selectedVersion = TestLauncherContent.Version("shockwave", isSelected: true); + + launcherData.AddOrUpdate(installedVersion); + launcherData.AddOrUpdate(selectedVersion); + + LauncherContent modification = launcherData.Modifications.Should().ContainSingle().Which; + modification.Versions.Should().ContainSingle(); + modification.Installed.Should().BeTrue(); + modification.IsSelected.Should().BeTrue(); + } + + [Fact] + public void Delete_RemovesMatchingVersionAndDeletesEmptyContentCard() + { + LauncherData launcherData = new(); + LauncherContentVersion versionOne = TestLauncherContent.Version("Shockwave"); + LauncherContentVersion versionTwo = TestLauncherContent.Version("Shockwave", "2.0"); + launcherData.AddOrUpdate(versionOne); + launcherData.AddOrUpdate(versionTwo); + + launcherData.DeleteVersion(versionOne.ContentKey); + launcherData.DeleteVersion(versionTwo.ContentKey); + + launcherData.Modifications.Should().BeEmpty(); + } + + [Fact] + public void DeleteVersion_WhenAnotherVersionRemains_KeepsTheCardAndItsDependentContent() + { + LauncherData launcherData = new(); + LauncherContentVersion firstVersion = TestLauncherContent.Version("Parent"); + LauncherContentVersion secondVersion = TestLauncherContent.Version("Parent", "2.0"); + LauncherContentVersion addon = TestLauncherContent.Version( + "Addon", + type: ModificationType.Addon, + parentContentName: "Parent"); + launcherData.AddOrUpdate(firstVersion); + launcherData.AddOrUpdate(secondVersion); + launcherData.AddOrUpdate(addon); + + launcherData.DeleteVersion(secondVersion.ContentKey); + + LauncherContent parent = launcherData.Modifications.Should().ContainSingle().Which; + parent.Versions.Should().ContainSingle().Which.Should().BeSameAs(firstVersion); + launcherData.Addons.Should().ContainSingle(); + } + + [Fact] + public void DeleteVersion_WhenTheContentCardIsMissing_KeepsDependentContent() + { + LauncherData launcherData = new(); + LauncherContentVersion addon = TestLauncherContent.Version( + "Addon", + type: ModificationType.Addon, + parentContentName: "Parent"); + launcherData.AddOrUpdate(addon); + + launcherData.DeleteVersion(new LauncherContentKey(ModificationType.Mod, string.Empty, "Parent", "1.0")); + + launcherData.Addons.Should().ContainSingle(); + } + + [Fact] + public void DeleteContent_WhenTheContentCardIsMissing_KeepsDependentContent() + { + LauncherData launcherData = new(); + LauncherContentVersion addon = TestLauncherContent.Version( + "Addon", + type: ModificationType.Addon, + parentContentName: "Parent"); + launcherData.AddOrUpdate(addon); + + launcherData.DeleteContent(LauncherContentKey.ForModificationName("Parent")); + + launcherData.Addons.Should().ContainSingle(); + } + + [Fact] + public void AddOrUpdate_KeepsChildCardsWithSameNameUnderDifferentParentsSeparate() + { + LauncherData launcherData = new(); + LauncherContentVersion firstAddon = TestLauncherContent.Version( + "Shared Addon", + type: ModificationType.Addon, + parentContentName: "First"); + LauncherContentVersion secondAddon = TestLauncherContent.Version( + "Shared Addon", + type: ModificationType.Addon, + parentContentName: "Second"); + + launcherData.AddOrUpdate(firstAddon); + launcherData.AddOrUpdate(secondAddon); + + launcherData.Addons.Should().HaveCount(2); + launcherData.Addons.Should().ContainSingle(addon => addon.ContentKey.ParentIdentity == "First"); + launcherData.Addons.Should().ContainSingle(addon => addon.ContentKey.ParentIdentity == "Second"); + } + + [Fact] + public void FindContent_UsesTypeParentNameAndOmitsVersionForCardLookup() + { + LauncherData launcherData = new(); + LauncherContentVersion firstAddon = TestLauncherContent.Version( + "Shared Addon", + "1.0", + ModificationType.Addon, + "First"); + LauncherContentVersion secondAddon = TestLauncherContent.Version( + "Shared Addon", + "2.0", + ModificationType.Addon, + "Second"); + launcherData.AddOrUpdate(firstAddon); + launcherData.AddOrUpdate(secondAddon); + + LauncherContent? found = launcherData.FindContent(new LauncherContentKey( + ModificationType.Addon, + "second", + "shared addon", + "different version")); + + found.Should().NotBeNull(); + found!.ContentKey.ParentIdentity.Should().Be("Second"); + found.Versions.Should().ContainSingle().Which.Should().BeSameAs(secondAddon); + } + + [Fact] + public void DeleteContent_RemovesEveryVersionAndDependentAddonAndPatchCards() + { + LauncherData launcherData = new(); + LauncherContentVersion mod = TestLauncherContent.Version("Parent"); + LauncherContentVersion secondModVersion = TestLauncherContent.Version("Parent", "2.0"); + LauncherContentVersion addon = TestLauncherContent.Version( + "Addon", + type: ModificationType.Addon, + parentContentName: "Parent"); + LauncherContentVersion patch = TestLauncherContent.Version( + "Patch", + type: ModificationType.Patch, + parentContentName: "Parent"); + LauncherContentVersion patchAddon = TestLauncherContent.Version( + "Patch Addon", + type: ModificationType.Addon, + parentContentName: "Patch"); + LauncherContentVersion unrelatedAddon = TestLauncherContent.Version( + "Addon", + type: ModificationType.Addon, + parentContentName: "Other"); + launcherData.AddOrUpdate(mod); + launcherData.AddOrUpdate(secondModVersion); + launcherData.AddOrUpdate(addon); + launcherData.AddOrUpdate(patch); + launcherData.AddOrUpdate(patchAddon); + launcherData.AddOrUpdate(unrelatedAddon); + + launcherData.DeleteContent(mod.ContentKey); + + launcherData.Modifications.Should().BeEmpty(); + launcherData.Addons.Should().ContainSingle().Which.ContentKey.ParentIdentity.Should().Be("Other"); + launcherData.Patches.Should().BeEmpty(); + } + + [Fact] + public void DeleteAddon_RemovesOnlyMatchingAddonCard() + { + LauncherData launcherData = new(); + LauncherContentVersion addon = TestLauncherContent.Version( + "Addon", + type: ModificationType.Addon, + parentContentName: "Shockwave"); + LauncherContentVersion patch = TestLauncherContent.Version("Addon", type: ModificationType.Patch); + launcherData.AddOrUpdate(addon); + launcherData.AddOrUpdate(patch); + + launcherData.DeleteVersion(addon.ContentKey); + + launcherData.Addons.Should().BeEmpty(); + launcherData.Patches.Should().ContainSingle(); + } + + [Fact] + public void DeletePatch_RemovesOnlyMatchingPatchCard() + { + LauncherData launcherData = new(); + LauncherContentVersion addon = TestLauncherContent.Version( + "Patch", + type: ModificationType.Addon, + parentContentName: "Shockwave"); + LauncherContentVersion patch = TestLauncherContent.Version("Patch", type: ModificationType.Patch); + launcherData.AddOrUpdate(addon); + launcherData.AddOrUpdate(patch); + + launcherData.DeleteVersion(patch.ContentKey); + + launcherData.Patches.Should().BeEmpty(); + launcherData.Addons.Should().ContainSingle(); + } + + [Fact] + public void DeletePatchAlso_DeletesDependentAddonCards() + { + LauncherData launcherData = new(); + LauncherContentVersion patch = TestLauncherContent.Version( + "Patch", + type: ModificationType.Patch, + parentContentName: "Shockwave"); + LauncherContentVersion dependentAddon = TestLauncherContent.Version( + "Addon", + type: ModificationType.Addon, + parentContentName: "Patch"); + LauncherContentVersion unrelatedAddon = TestLauncherContent.Version( + "Addon", + type: ModificationType.Addon, + parentContentName: "Other"); + launcherData.AddOrUpdate(patch); + launcherData.AddOrUpdate(dependentAddon); + launcherData.AddOrUpdate(unrelatedAddon); + + launcherData.DeleteVersion(patch.ContentKey); + + launcherData.Patches.Should().BeEmpty(); + launcherData.Addons.Should().ContainSingle().Which.ContentKey.ParentIdentity.Should().Be("Other"); + } + + /// + /// Advertising has no persistent collection behind it, so every catalog operation has to route on the content + /// type rather than assume one. Removal is included because a card that was never stored still reaches the + /// delete paths when a tile is discarded. + /// + [Fact] + public void AddOrUpdate_DoesNotEmbedAdvertisingInPersistentContentCollections() + { + LauncherData launcherData = new(); + LauncherContentVersion modification = TestLauncherContent.Version("ShockWave"); + LauncherContentVersion advertising = TestLauncherContent.Version( + "Featured", + type: ModificationType.Advertising); + launcherData.AddOrUpdate(modification); + launcherData.AddOrUpdate(advertising); + + launcherData.DeleteVersion(advertising.ContentKey); + launcherData.DeleteContent(advertising.ContentKey); + + launcherData.Modifications.Should().ContainSingle() + .Which.ContentKey.Should().Be(modification.ContentKey.WithoutVersion()); + launcherData.FindContent(advertising.ContentKey).Should().BeNull(); + } + + [Fact] + public void PersistedSelectedModificationQuery_ReturnsSelectedCard() + { + LauncherData launcherData = new(); + LauncherContentVersion selectedVersion = TestLauncherContent.Version("ShockWave", isSelected: true); + launcherData.AddOrUpdate(selectedVersion); + launcherData.AddOrUpdate(TestLauncherContent.Version("ShockWave", "1.1")); + + LauncherContent? selectedModification = launcherData.GetSelectedMod(); + + selectedModification.Should().NotBeNull(); + selectedModification!.Name.Should().Be("ShockWave"); + } + + [Fact] + public void OriginalGameContent_QueriesUseOriginalGameDependenciesWhenNoParentIsSupplied() + { + LauncherData launcherData = new(); + LauncherContentVersion patch = TestLauncherContent.Version( + "Original Patch", + type: ModificationType.Patch, + parentContentName: LauncherContentKey.OriginalGame.Name, + isSelected: true); + LauncherContentVersion originalAddon = TestLauncherContent.Version( + "Original Addon", + "2.0", + ModificationType.Addon, + LauncherContentKey.OriginalGame.Name, + isSelected: true); + LauncherContentVersion patchAddon = TestLauncherContent.Version( + "Patch Addon", + "3.0", + ModificationType.Addon, + "Original Patch", + isSelected: true); + launcherData.AddOrUpdate(patch); + launcherData.AddOrUpdate(originalAddon); + launcherData.AddOrUpdate(patchAddon); + + LauncherContent selectedPatch = launcherData.Patches.Single(); + IReadOnlyList patches = launcherData.GetPatchesFor(null); + IReadOnlyList addons = launcherData.GetAddonsFor(null, selectedPatch); + + patches.Select(item => item.Name).Should().Equal("Original Patch"); + addons.Select(addon => addon.Name).Should().Equal("Original Addon", "Patch Addon"); + } + + [Fact] + public void GetAddonsFor_IncludesPatchDependentAddons() + { + LauncherData launcherData = new(); + LauncherContentVersion modification = TestLauncherContent.Version("ShockWave", isSelected: true); + LauncherContentVersion patch = TestLauncherContent.Version( + "ShockWave Patch", + "1.1", + ModificationType.Patch, + "ShockWave", + isSelected: true); + LauncherContentVersion patchAddon = TestLauncherContent.Version( + "Patch Addon", + "2.0", + ModificationType.Addon, + "ShockWave Patch", + isSelected: true); + launcherData.AddOrUpdate(modification); + launcherData.AddOrUpdate(patch); + launcherData.AddOrUpdate(patchAddon); + launcherData.AddOrUpdate(TestLauncherContent.Version( + "Mod Addon", + "3.0", + ModificationType.Addon, + "ShockWave")); + + LauncherContent selectedModification = launcherData.Modifications.Single(); + LauncherContent selectedPatch = launcherData.Patches.Single(); + IReadOnlyList addons = launcherData.GetAddonsFor( + selectedModification, + selectedPatch); + + addons.Select(addon => addon.Name).Should().Equal("Mod Addon", "Patch Addon"); + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Models/OwnedContentPathTests.cs b/GenLauncherGO.Tests/Core/Mods/Models/OwnedContentPathTests.cs new file mode 100644 index 00000000..01aff290 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Models/OwnedContentPathTests.cs @@ -0,0 +1,35 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Core.Mods.Models; + +public sealed class OwnedContentPathTests +{ + [Fact] + public void Constructor_NormalizesOwnedChildAndExposesRelativePath() + { + string ownerRoot = TestLauncherPaths.CreateVirtualRoot("OwnedContentPath").ModsDirectory; + string fullPath = Path.Combine(ownerRoot, "ShockWave", "..", "ShockWave", "1.2"); + + var result = new OwnedContentPath(ownerRoot, fullPath); + + result.OwnerRoot.Should().Be(ownerRoot); + result.FullPath.Should().Be(Path.Combine(ownerRoot, "ShockWave", "1.2")); + result.RelativePath.Should().Be("ShockWave/1.2"); + } + + [Theory] + [InlineData(".")] + [InlineData(@"..\Outside")] + public void Constructor_RejectsPathOutsideOwnershipBoundary(string relativeFragment) + { + string ownerRoot = TestLauncherPaths.CreateVirtualRoot("OwnershipBoundary").ModsDirectory; + string fullPath = Path.Combine(ownerRoot, relativeFragment); + + Action act = () => new OwnedContentPath(ownerRoot, fullPath); + + act.Should().Throw() + .WithParameterName("fullPath"); + } +} diff --git a/GenLauncherGO.Tests/Core/Mods/Services/LauncherContentPathResolverTests.cs b/GenLauncherGO.Tests/Core/Mods/Services/LauncherContentPathResolverTests.cs new file mode 100644 index 00000000..d89b36c8 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Mods/Services/LauncherContentPathResolverTests.cs @@ -0,0 +1,139 @@ +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 +{ + [Theory] + [InlineData(ModificationType.Mod, null, "Rise Of Reds", "1.9", "Rise Of Reds/1.9")] + [InlineData(ModificationType.Addon, "Rise Of Reds", "Music Pack", "2.0", "Rise Of Reds/Addons/Music Pack/2.0")] + [InlineData(ModificationType.Patch, "Rise Of Reds", "Hotfix", "2.1", "Rise Of Reds/Patches/Hotfix/2.1")] + public void ResolveVersionPath_WhenIdentityIsSupported_ReturnsOwnedVersionDirectory( + ModificationType modificationType, + string? parentContentName, + string name, + string version, + string expectedRelativePath) + { + LauncherPaths paths = CreatePaths(); + LauncherContentKey contentKey = new(modificationType, parentContentName, name, version); + + OwnedContentPath? result = LauncherContentPathResolver.ResolveVersionPath(paths, contentKey); + + AssertOwnedPath(result, paths, expectedRelativePath); + } + + [Fact] + public void ResolveVersionPath_WhenIdentityTypeIsUnsupported_ReturnsNull() + { + LauncherPaths paths = CreatePaths(); + LauncherContentKey contentKey = new(ModificationType.Advertising, null, "News", "1"); + + OwnedContentPath? result = LauncherContentPathResolver.ResolveVersionPath(paths, contentKey); + + result.Should().BeNull(); + } + + [Theory] + [InlineData(ModificationType.Mod, null, "../Escape", "1.0")] + [InlineData(ModificationType.Addon, "../Escape", "Music Pack", "1.0")] + [InlineData(ModificationType.Patch, "Rise Of Reds", "Hotfix", "../Escape")] + [InlineData(ModificationType.Mod, null, "CON", "1.0")] + [InlineData(ModificationType.Mod, null, "Rise Of Reds", "1.0.")] + [InlineData(ModificationType.Addon, @"C:\Escape", "Music Pack", "1.0")] + public void ResolveVersionPath_WhenIdentityIsUnsafeForAPathSegment_Throws( + ModificationType modificationType, + string? parentContentName, + string name, + string version) + { + LauncherPaths paths = CreatePaths(); + LauncherContentKey contentKey = new(modificationType, parentContentName, name, version); + + Action act = () => LauncherContentPathResolver.ResolveVersionPath(paths, contentKey); + + act.Should().Throw(); + } + + [Theory] + [InlineData(ModificationType.Mod, "", "1.0", "")] + [InlineData(ModificationType.Mod, "ShockWave", "", "")] + [InlineData(ModificationType.Addon, "HD", "1.0", "")] + [InlineData(ModificationType.Patch, "Balance", "1.0", "")] + public void ResolveVersionPath_WhenIdentityIsIncomplete_ReturnsNull( + ModificationType modificationType, + string name, + string versionName, + string parentContentName) + { + LauncherPaths paths = CreatePaths(); + LauncherContentKey contentKey = new(modificationType, parentContentName, name, versionName); + + OwnedContentPath? result = LauncherContentPathResolver.ResolveVersionPath(paths, contentKey); + + result.Should().BeNull(); + } + + [Theory] + [InlineData(ModificationType.Mod, null, "ShockWave", "ShockWave", "ShockWave")] + [InlineData(ModificationType.Addon, "ShockWave", "Music", "ShockWave/Addons/Music", "ShockWave")] + [InlineData(ModificationType.Patch, "ShockWave", "Hotfix", "ShockWave/Patches/Hotfix", "ShockWave")] + public void ResolveContentPaths_WhenIdentityIsSupported_ReturnsOwnedMappedDirectories( + ModificationType modificationType, + string? parentContentName, + string name, + string expectedContentRelativePath, + string expectedCleanupRelativePath) + { + LauncherPaths paths = CreatePaths(); + LauncherContentKey contentKey = new(modificationType, parentContentName, name, "1.0"); + + OwnedContentPath? contentPath = LauncherContentPathResolver.ResolveContentPath(paths, contentKey); + OwnedContentPath? cleanupPath = LauncherContentPathResolver.ResolveCleanupRootPath(paths, contentKey); + + AssertOwnedPath(contentPath, paths, expectedContentRelativePath); + AssertOwnedPath(cleanupPath, paths, expectedCleanupRelativePath); + } + + [Theory] + [InlineData(ModificationType.Mod, null, "")] + [InlineData(ModificationType.Addon, null, "Music")] + [InlineData(ModificationType.Patch, "", "Hotfix")] + [InlineData(ModificationType.Advertising, null, "News")] + public void ResolveContentPaths_WhenIdentityIsIncompleteOrUnsupported_ReturnNull( + ModificationType modificationType, + string? parentContentName, + string name) + { + LauncherPaths paths = CreatePaths(); + LauncherContentKey contentKey = new(modificationType, parentContentName, name, "1.0"); + + OwnedContentPath? contentPath = LauncherContentPathResolver.ResolveContentPath(paths, contentKey); + OwnedContentPath? cleanupPath = LauncherContentPathResolver.ResolveCleanupRootPath(paths, contentKey); + + contentPath.Should().BeNull(); + cleanupPath.Should().BeNull(); + } + + private static void AssertOwnedPath( + OwnedContentPath? path, + LauncherPaths paths, + string expectedRelativePath) + { + string ownerRoot = Path.GetFullPath(paths.ModsDirectory); + string normalizedRelativePath = expectedRelativePath.Replace('/', Path.DirectorySeparatorChar); + + path.Should().NotBeNull(); + path!.OwnerRoot.Should().Be(ownerRoot); + path.FullPath.Should().Be(Path.Combine(ownerRoot, normalizedRelativePath)); + } + + private static LauncherPaths CreatePaths() + { + return TestLauncherPaths.CreateVirtualRoot("LauncherContentPathResolver"); + } +} diff --git a/GenLauncherGO.Tests/Core/Settings/Models/LauncherPreferencesTests.cs b/GenLauncherGO.Tests/Core/Settings/Models/LauncherPreferencesTests.cs new file mode 100644 index 00000000..3fe3dd8e --- /dev/null +++ b/GenLauncherGO.Tests/Core/Settings/Models/LauncherPreferencesTests.cs @@ -0,0 +1,74 @@ +using System; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Tests.Core.Settings.Models; + +public sealed class LauncherPreferencesTests +{ + [Theory] + [InlineData(SupportedGame.Generals, SupportedGame.ZeroHour, @"C:\Games\Generals")] + [InlineData(SupportedGame.ZeroHour, SupportedGame.Generals, @"D:\Games\ZeroHour")] + public void WithPath_RoundTripsThroughGetPath(SupportedGame game, SupportedGame otherGame, string path) + { + LauncherInstallations installations = new LauncherInstallations().WithPath(game, path); + + installations.GetPath(game).Should().Be(path); + installations.GetPath(otherGame).Should().BeNull(); + } + + [Fact] + public void InstallationsResolveConfiguredPreferredGame_WithSingleInstallationFallback() + { + var both = new LauncherInstallations + { + Generals = @"C:\Games\Generals", + ZeroHour = @"C:\Games\ZeroHour" + }; + var generalsOnly = new LauncherInstallations { Generals = @"C:\Games\Generals" }; + var zeroHourOnly = new LauncherInstallations { ZeroHour = @"C:\Games\ZeroHour" }; + + both.ResolvePreferredGame(SupportedGame.Generals).Should().Be(SupportedGame.Generals); + both.ResolvePreferredGame(SupportedGame.ZeroHour).Should().Be(SupportedGame.ZeroHour); + both.ResolvePreferredGame(null).Should().BeNull(); + generalsOnly.ResolvePreferredGame(SupportedGame.ZeroHour).Should().Be(SupportedGame.Generals); + zeroHourOnly.ResolvePreferredGame(SupportedGame.Generals).Should().Be(SupportedGame.ZeroHour); + new LauncherInstallations().ResolvePreferredGame(SupportedGame.Generals).Should().BeNull(); + } + + [Theory] + [InlineData(SupportedGame.Generals, SupportedGame.ZeroHour)] + [InlineData(SupportedGame.ZeroHour, SupportedGame.Generals)] + public void With_RoundTripsThroughGet(SupportedGame game, SupportedGame otherGame) + { + var preferences = new LauncherGamePreferences { GameArguments = "-quickstart" }; + LauncherGamePreferencesSet original = new(); + + LauncherGamePreferencesSet games = original.With(game, preferences); + + games.Get(game).Should().BeSameAs(preferences); + games.Get(otherGame).Should().BeSameAs(original.Get(otherGame)); + } + + [Fact] + public void CustomExecutable_TrimsDisplayNameAndNormalizesExecutableName() + { + LauncherCustomExecutable executable = new(" My Client ", " custom.exe "); + + executable.DisplayName.Should().Be("My Client"); + executable.ExecutableName.Should().Be("custom.exe"); + } + + [Theory] + [InlineData(@"tools\custom.exe")] + [InlineData(@"..\custom.exe")] + [InlineData("custom.txt")] + [InlineData("CON.exe")] + public void CustomExecutable_WithUnsafeExecutableName_Throws(string executableName) + { + Action act = () => new LauncherCustomExecutable("My Client", executableName); + + act.Should().Throw() + .WithParameterName(nameof(executableName)); + } +} diff --git a/GenLauncherGO.Tests/Core/Startup/GameInstallationServiceExtensionsTests.cs b/GenLauncherGO.Tests/Core/Startup/GameInstallationServiceExtensionsTests.cs new file mode 100644 index 00000000..69033e24 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Startup/GameInstallationServiceExtensionsTests.cs @@ -0,0 +1,154 @@ +using System; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Startup.Contracts; +using GenLauncherGO.Core.Startup.Models; + +namespace GenLauncherGO.Tests.Core.Startup; + +public sealed class GameInstallationServiceExtensionsTests +{ + [Fact] + public void ValidateInstallations_ReportsIdenticalEnteredPathsBeforeFilesystemFailures() + { + IGameInstallationService service = Substitute.For(); + service.Validate( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(GameInstallationValidationResult.Invalid( + GameInstallationValidationFailure.DirectoryNotFound)); + + LauncherInstallationsValidationResult result = service.ValidateInstallations( + new LauncherInstallations + { + Generals = @"C:\Missing\Game", + ZeroHour = @" c:\missing\game " + }, + @"C:\Launcher"); + + result.HasDuplicatePath.Should().BeTrue(); + result.IsValid.Should().BeFalse(); + } + + [Fact] + public void ValidateInstallations_WithNoConfiguredPaths_RejectsSet() + { + IGameInstallationService service = CreateService((_, _) => MissingPath()); + + LauncherInstallationsValidationResult result = service.ValidateInstallations( + new LauncherInstallations(), + @"C:\Launcher"); + + result.IsValid.Should().BeFalse(); + result.HasDuplicatePath.Should().BeFalse(); + result.CanonicalInstallations.Should().Be(new LauncherInstallations()); + } + + [Fact] + public void ValidateInstallations_WithOneValidPath_ReturnsCanonicalSet() + { + const string CanonicalPath = @"C:\Games\Generals"; + IGameInstallationService service = CreateService((game, path) => + game == SupportedGame.Generals && !string.IsNullOrWhiteSpace(path) + ? GameInstallationValidationResult.Valid(CanonicalPath) + : MissingPath()); + + LauncherInstallationsValidationResult result = service.ValidateInstallations( + new LauncherInstallations { Generals = @"C:\Games\GENERALS" }, + @"C:\Launcher"); + + result.IsValid.Should().BeTrue(); + result.HasDuplicatePath.Should().BeFalse(); + result.CanonicalInstallations.Should().Be( + new LauncherInstallations { Generals = CanonicalPath }); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ValidateInstallations_WithABlankPath_TreatsItAsNotConfigured(string blankPath) + { + const string ZeroHourPath = @"C:\Games\ZeroHour"; + IGameInstallationService service = CreateService((game, path) => + game == SupportedGame.ZeroHour && !string.IsNullOrWhiteSpace(path) + ? GameInstallationValidationResult.Valid(ZeroHourPath) + : MissingPath()); + + LauncherInstallationsValidationResult result = service.ValidateInstallations( + new LauncherInstallations { Generals = blankPath, ZeroHour = ZeroHourPath }, + @"C:\Launcher"); + + result.IsValid.Should().BeTrue(); + result.CanonicalInstallations.Should().Be( + new LauncherInstallations { ZeroHour = ZeroHourPath }); + } + + [Fact] + public void ValidateInstallations_WithInvalidNonemptyPath_RejectsSet() + { + IGameInstallationService service = CreateService((game, path) => + { + if (string.IsNullOrWhiteSpace(path)) + { + return MissingPath(); + } + + return game == SupportedGame.Generals + ? GameInstallationValidationResult.Valid(@"C:\Games\Generals") + : GameInstallationValidationResult.Invalid( + GameInstallationValidationFailure.DirectoryNotFound); + }); + + LauncherInstallationsValidationResult result = service.ValidateInstallations( + new LauncherInstallations + { + Generals = @"C:\Games\Generals", + ZeroHour = @"C:\Not-Zero-Hour" + }, + @"C:\Launcher"); + + result.IsValid.Should().BeFalse(); + result.CanonicalInstallations.Should().Be( + new LauncherInstallations { Generals = @"C:\Games\Generals" }); + } + + [Fact] + public void ValidateInstallations_WithSameCanonicalPath_RejectsDuplicate() + { + IGameInstallationService service = CreateService((game, _) => + GameInstallationValidationResult.Valid( + game == SupportedGame.Generals ? @"C:\Games\Shared" : @"c:\games\shared")); + + LauncherInstallationsValidationResult result = service.ValidateInstallations( + new LauncherInstallations + { + Generals = @"C:\Games\GeneralsAlias", + ZeroHour = @"C:\Games\ZeroHourAlias" + }, + @"C:\Launcher"); + + result.IsValid.Should().BeFalse(); + result.HasDuplicatePath.Should().BeTrue(); + } + + private static IGameInstallationService CreateService( + Func validation) + { + IGameInstallationService service = Substitute.For(); + service.Validate( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(call => validation( + call.ArgAt(0), + call.ArgAt(1))); + return service; + } + + private static GameInstallationValidationResult MissingPath() + { + return GameInstallationValidationResult.Invalid( + GameInstallationValidationFailure.PathMissing); + } +} diff --git a/GenLauncherGO.Tests/Core/Startup/LauncherFileSystemLayoutTests.cs b/GenLauncherGO.Tests/Core/Startup/LauncherFileSystemLayoutTests.cs new file mode 100644 index 00000000..269796bc --- /dev/null +++ b/GenLauncherGO.Tests/Core/Startup/LauncherFileSystemLayoutTests.cs @@ -0,0 +1,83 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Tests.Core.Startup; + +public sealed class LauncherFileSystemLayoutTests +{ + [Fact] + public void GetBuiltInGameExecutableNames_ForZeroHour_ListsOnlineThenCommunityThenRetail() + { + IReadOnlyList executableNames = + LauncherFileSystemLayout.GetBuiltInGameExecutableNames(SupportedGame.ZeroHour); + + executableNames.Should().Equal("generalsonlinezh.exe", "generalszh.exe", "generals.exe"); + } + + [Fact] + public void GetBuiltInGameExecutableNames_ForGenerals_ListsCommunityThenRetail() + { + IReadOnlyList executableNames = + LauncherFileSystemLayout.GetBuiltInGameExecutableNames(SupportedGame.Generals); + + executableNames.Should().Equal("generalsv.exe", "generals.exe"); + } + + [Fact] + public void GetBuiltInWorldBuilderExecutableNames_ForZeroHour_ListsRetailThenCommunity() + { + IReadOnlyList executableNames = + LauncherFileSystemLayout.GetBuiltInWorldBuilderExecutableNames(SupportedGame.ZeroHour); + + executableNames.Should().Equal("WorldBuilder.exe", "worldbuilderzh.exe"); + } + + [Fact] + public void GetBuiltInWorldBuilderExecutableNames_ForGenerals_ListsRetailThenCommunity() + { + IReadOnlyList executableNames = + LauncherFileSystemLayout.GetBuiltInWorldBuilderExecutableNames(SupportedGame.Generals); + + executableNames.Should().Equal("WorldBuilder.exe", "worldbuilderv.exe"); + } + + [Theory] + [InlineData(" custom.exe ", "custom.exe")] + [InlineData("Custom.EXE", "Custom.EXE")] + public void NormalizeExecutableFileName_TrimsAndKeepsAnyCaseExeExtension( + string executableName, + string expectedExecutableName) + { + string normalizedName = LauncherFileSystemLayout.NormalizeExecutableFileName(executableName); + + normalizedName.Should().Be(expectedExecutableName); + } + + [Theory] + [InlineData("custom")] + [InlineData("custom.txt")] + [InlineData("custom.exe.txt")] + public void NormalizeExecutableFileName_WithoutExeExtension_Throws(string executableName) + { + Action act = () => LauncherFileSystemLayout.NormalizeExecutableFileName(executableName); + + act.Should().Throw() + .WithParameterName(nameof(executableName)); + } + + [Theory] + [InlineData(@"tools\custom.exe")] + [InlineData("tools/custom.exe")] + [InlineData(@"..\custom.exe")] + [InlineData(@"C:\tools\custom.exe")] + [InlineData("CON.exe")] + [InlineData("")] + public void NormalizeExecutableFileName_WithUnsafeRootLevelName_Throws(string executableName) + { + Action act = () => LauncherFileSystemLayout.NormalizeExecutableFileName(executableName); + + act.Should().Throw() + .WithParameterName(nameof(executableName)); + } +} diff --git a/GenLauncherGO.Tests/Core/Startup/LauncherPathsTests.cs b/GenLauncherGO.Tests/Core/Startup/LauncherPathsTests.cs new file mode 100644 index 00000000..2cdb18b8 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Startup/LauncherPathsTests.cs @@ -0,0 +1,151 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Tests.Core.Startup; + +public sealed class LauncherPathsTests +{ + [Fact] + public void Constructor_NormalizesRootsAndDerivesCanonicalLayout() + { + string gameDirectory = Path.Combine("GenLauncherGO.Tests", "Game", "."); + string executableDirectory = Path.Combine("GenLauncherGO.Tests", "Launcher", "."); + + var storagePaths = new LauncherStoragePaths(executableDirectory); + LauncherPaths paths = storagePaths.CreateGamePaths(SupportedGame.ZeroHour, gameDirectory); + string expectedGameRoot = Path.GetFullPath(gameDirectory); + string expectedOwnedGameDataRoot = Path.Combine( + storagePaths.DataDirectory, + "C&C Zero Hour Data"); + + paths.Game.Should().Be(SupportedGame.ZeroHour); + paths.GameDirectory.Should().Be(expectedGameRoot); + paths.OwnedGameDataDirectory.Should().Be(expectedOwnedGameDataRoot); + paths.RuntimeDirectory.Should().Be(Path.Combine(expectedOwnedGameDataRoot, "Runtime")); + paths.CacheDirectory.Should().Be(Path.Combine(expectedOwnedGameDataRoot, "Runtime", "Cache")); + paths.ImagesDirectory.Should().Be(Path.Combine(expectedOwnedGameDataRoot, "Runtime", "Cache", "Images")); + paths.ModsDirectory.Should().Be(Path.Combine(expectedOwnedGameDataRoot, "Mods")); + paths.TempDirectory.Should().Be(Path.Combine(expectedOwnedGameDataRoot, "Runtime", "Temp")); + paths.DeploymentDirectory.Should().Be(Path.Combine(expectedOwnedGameDataRoot, "Runtime", "Deployment")); + paths.StateDirectory.Should().Be(Path.Combine(expectedOwnedGameDataRoot, "Runtime", "State")); + paths.IntegrityDirectory.Should().Be(Path.Combine(expectedOwnedGameDataRoot, "Runtime", "Integrity")); + paths.PackageBackupsDirectory.Should().Be(Path.Combine( + expectedOwnedGameDataRoot, + "Runtime", + "State", + LauncherFileSystemLayout.PackageBackupsFolderName)); + } + + [Fact] + public void GetPackageTemporaryPath_BuildsOwnedPathUnderTempDirectory() + { + LauncherPaths paths = TestLauncherPaths.Create(); + string installedFolderPath = Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"); + + OwnedContentPath temporaryPath = paths.GetPackageTemporaryPath( + new OwnedContentPath(paths.ModsDirectory, installedFolderPath)); + + temporaryPath.OwnerRoot.Should().Be(paths.PackagesDirectory); + temporaryPath.FullPath.Should().Be(Path.Combine(paths.TempDirectory, "Packages", "ShockWave", "1.2")); + } + + [Fact] + public void GetPackageTemporaryPath_UsesFolderNameWhenInstallIsOutsideModsDirectory() + { + LauncherPaths paths = TestLauncherPaths.Create(); + string installedFolderPath = Path.Combine(paths.GameDirectory, "Data"); + + OwnedContentPath temporaryPath = paths.GetPackageTemporaryPath( + new OwnedContentPath(paths.GameDirectory, installedFolderPath)); + + temporaryPath.FullPath.Should().Be(Path.Combine(paths.TempDirectory, "Packages", "Data")); + } + + [Fact] + public void GetPackageTemporaryPath_PreservesModsChildNamesThatStartWithDots() + { + LauncherPaths paths = TestLauncherPaths.Create(); + string installedFolderPath = Path.Combine(paths.ModsDirectory, "..cache", "1.0"); + + OwnedContentPath temporaryPath = paths.GetPackageTemporaryPath( + new OwnedContentPath(paths.ModsDirectory, installedFolderPath)); + + temporaryPath.FullPath.Should().Be(Path.Combine(paths.TempDirectory, "Packages", "..cache", "1.0")); + } + + [Fact] + public void GetPackageBackupPathMirrorsModsRelativePath_UnderDurableStateDirectory() + { + LauncherPaths paths = TestLauncherPaths.Create(); + string installedFolderPath = Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"); + + OwnedContentPath backupPath = paths.GetPackageBackupPath( + new OwnedContentPath(paths.ModsDirectory, installedFolderPath)); + + string backupRoot = Path.Combine( + paths.StateDirectory, + LauncherFileSystemLayout.PackageBackupsFolderName); + backupPath.OwnerRoot.Should().Be(backupRoot); + backupPath.FullPath.Should().Be(Path.Combine(backupRoot, "ShockWave", "1.2")); + } + + [Fact] + public void GetPackageBackupPath_RejectsInstallOutsideModsDirectory() + { + LauncherPaths paths = TestLauncherPaths.Create(); + var installedPath = new OwnedContentPath( + paths.GameDirectory, + Path.Combine(paths.GameDirectory, "Data")); + + Action act = () => paths.GetPackageBackupPath(installedPath); + + act.Should().Throw() + .WithParameterName("installedPath"); + } + + [Fact] + public void LauncherDataFilePath_BuildsPathUnderRuntimeStateDirectory() + { + LauncherPaths paths = TestLauncherPaths.Create(); + + string launcherDataFilePath = paths.LauncherDataFilePath; + + launcherDataFilePath.Should().Be( + Path.Combine(paths.RuntimeDirectory, "State", "LauncherData.yaml")); + } + + [Fact] + public void GetModificationImageFilePath_BuildsPathUnderModificationImageCache() + { + LauncherPaths paths = TestLauncherPaths.Create(); + + string imageFilePath = paths.GetModificationImageFilePath("ShockWave", "1.2.png"); + + imageFilePath.Should().Be(Path.Combine(paths.ImagesDirectory, "ShockWave", "1.2.png")); + } + + [Fact] + public void GetModificationImagesDirectory_ThrowsForPathTraversalModificationName() + { + LauncherPaths paths = TestLauncherPaths.Create(); + + Action act = () => paths.GetModificationImagesDirectory($"..{Path.DirectorySeparatorChar}Escape"); + + act.Should().Throw(); + } + + [Fact] + public void GetModificationImageFilePath_ThrowsForPathTraversalImageFileName() + { + LauncherPaths paths = TestLauncherPaths.Create(); + + Action act = () => paths.GetModificationImageFilePath( + "ShockWave", + $"..{Path.DirectorySeparatorChar}1.2.png"); + + act.Should().Throw(); + } + +} diff --git a/GenLauncherGO.Tests/Core/Startup/LauncherRuntimePathContextTests.cs b/GenLauncherGO.Tests/Core/Startup/LauncherRuntimePathContextTests.cs new file mode 100644 index 00000000..a33de2f3 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Startup/LauncherRuntimePathContextTests.cs @@ -0,0 +1,55 @@ +using System; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Tests.Core.Startup; + +public sealed class LauncherRuntimePathContextTests +{ + [Fact] + public void SwitchActiveAtomically_ReplacesImmutablePathSnapshot() + { + var storagePaths = new LauncherStoragePaths(@"C:\Launcher"); + LauncherPaths generalsPaths = storagePaths.CreateGamePaths( + SupportedGame.Generals, + @"C:\Games\Generals"); + LauncherPaths zeroHourPaths = storagePaths.CreateGamePaths( + SupportedGame.ZeroHour, + @"D:\Games\Zero Hour"); + var context = new LauncherRuntimePathContext(storagePaths, generalsPaths); + + context.SwitchActive(zeroHourPaths); + + context.ActivePaths.Should().BeSameAs(zeroHourPaths); + context.StoragePaths.Should().BeSameAs(storagePaths); + } + + [Fact] + public void SwitchActive_WithPathsOwnedByAnotherStorageRoot_RejectsAndKeepsActiveSnapshot() + { + var storagePaths = new LauncherStoragePaths(@"C:\Launcher"); + LauncherPaths generalsPaths = storagePaths.CreateGamePaths( + SupportedGame.Generals, + @"C:\Games\Generals"); + LauncherPaths foreignPaths = new LauncherStoragePaths(@"D:\OtherLauncher") + .CreateGamePaths(SupportedGame.ZeroHour, @"D:\Games\Zero Hour"); + var context = new LauncherRuntimePathContext(storagePaths, generalsPaths); + + Action act = () => context.SwitchActive(foreignPaths); + + act.Should().Throw(); + context.ActivePaths.Should().BeSameAs(generalsPaths); + } + + [Fact] + public void Constructor_RejectsPathsOwnedByAnotherLauncherStorageRoot() + { + var storagePaths = new LauncherStoragePaths(@"C:\Launcher"); + LauncherPaths foreignPaths = new LauncherStoragePaths(@"D:\OtherLauncher") + .CreateGamePaths(SupportedGame.ZeroHour, @"C:\Games\Zero Hour"); + + Action act = () => new LauncherRuntimePathContext(storagePaths, foreignPaths); + + act.Should().Throw() + .WithMessage("*canonical per-game data directory*"); + } +} diff --git a/GenLauncherGO.Tests/Core/Startup/LauncherStoragePathsTests.cs b/GenLauncherGO.Tests/Core/Startup/LauncherStoragePathsTests.cs new file mode 100644 index 00000000..6c9add62 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Startup/LauncherStoragePathsTests.cs @@ -0,0 +1,56 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Tests.Core.Startup; + +public sealed class LauncherStoragePathsTests +{ + [Theory] + [InlineData(SupportedGame.Generals, "C&C Generals Data")] + [InlineData(SupportedGame.ZeroHour, "C&C Zero Hour Data")] + public void Constructor_DerivesSharedStandaloneAndIsolatedGamePaths( + SupportedGame game, + string expectedGameDataFolderName) + { + string executableDirectory = Path.GetFullPath(Path.Combine("GenLauncherGO.Tests", "Launcher")); + string dataDirectory = Path.Combine( + executableDirectory, + LauncherFileSystemLayout.LauncherDataFolderName); + var storage = new LauncherStoragePaths(executableDirectory); + + LauncherPaths gamePaths = storage.CreateGamePaths( + game, + Path.GetFullPath(Path.Combine("GenLauncherGO.Tests", "Game"))); + + storage.ExecutableDirectory.Should().Be(executableDirectory); + storage.DataDirectory.Should().Be(dataDirectory); + storage.LogsDirectory.Should().Be(Path.Combine(dataDirectory, "Logs")); + storage.PreferencesFilePath.Should().Be(Path.Combine(dataDirectory, "LauncherPreferences.yaml")); + gamePaths.OwnedGameDataDirectory.Should().Be(Path.Combine(dataDirectory, expectedGameDataFolderName)); + } + + [Fact] + public void CreateGamePaths_IsolatesEachSupportedGameDataDirectory() + { + var storage = new LauncherStoragePaths( + Path.GetFullPath(Path.Combine("GenLauncherGO.Tests", "Launcher"))); + string gameDirectory = Path.GetFullPath(Path.Combine("GenLauncherGO.Tests", "Game")); + + LauncherPaths generalsPaths = storage.CreateGamePaths(SupportedGame.Generals, gameDirectory); + LauncherPaths zeroHourPaths = storage.CreateGamePaths(SupportedGame.ZeroHour, gameDirectory); + + generalsPaths.OwnedGameDataDirectory.Should().NotBe(zeroHourPaths.OwnedGameDataDirectory); + } + + [Fact] + public void CreateGamePaths_WithUnsupportedGame_Throws() + { + var storage = new LauncherStoragePaths(Path.GetFullPath("Launcher")); + + Action act = () => storage.CreateGamePaths(SupportedGame.Unknown, Path.GetFullPath("Game")); + + act.Should().Throw() + .WithParameterName("managedGame"); + } +} diff --git a/GenLauncherGO.Tests/Core/Startup/Models/GameInstallationValidationResultTests.cs b/GenLauncherGO.Tests/Core/Startup/Models/GameInstallationValidationResultTests.cs new file mode 100644 index 00000000..9fcc1321 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Startup/Models/GameInstallationValidationResultTests.cs @@ -0,0 +1,17 @@ +using System; +using GenLauncherGO.Core.Startup.Models; + +namespace GenLauncherGO.Tests.Core.Startup.Models; + +public sealed class GameInstallationValidationResultTests +{ + [Fact] + public void Invalid_WithoutAFailure_Throws() + { + Action act = () => GameInstallationValidationResult.Invalid( + GameInstallationValidationFailure.None); + + act.Should().Throw() + .WithParameterName("failure"); + } +} diff --git a/GenLauncherGO.Tests/Core/Updating/Models/PackageDownloadPauseControllerTests.cs b/GenLauncherGO.Tests/Core/Updating/Models/PackageDownloadPauseControllerTests.cs new file mode 100644 index 00000000..89878856 --- /dev/null +++ b/GenLauncherGO.Tests/Core/Updating/Models/PackageDownloadPauseControllerTests.cs @@ -0,0 +1,89 @@ +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Tests.Core.Updating.Models; + +public sealed class PackageDownloadPauseControllerTests +{ + [Fact] + public void WaitWhilePausedAsync_WithoutController_CompletesImmediately() + { + ValueTask wait = PackageDownloadPauseController.WaitWhilePausedAsync(null, CancellationToken.None); + + wait.IsCompletedSuccessfully.Should().BeTrue(); + } + + [Fact] + public async Task PauseAndResume_AreIdempotentAndExposeCurrentStateAsync() + { + PackageDownloadPauseController controller = new(); + + controller.IsPaused.Should().BeFalse(); + controller.WaitWhilePausedAsync(CancellationToken.None).AsTask() + .IsCompletedSuccessfully.Should().BeTrue(); + controller.Resume().Should().BeFalse(); + controller.Pause().Should().BeTrue(); + controller.Pause().Should().BeFalse(); + controller.IsPaused.Should().BeTrue(); + + Task waiter = controller.WaitWhilePausedAsync(CancellationToken.None).AsTask(); + waiter.IsCompleted.Should().BeFalse(); + controller.Resume().Should().BeTrue(); + controller.Resume().Should().BeFalse(); + await waiter.WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + controller.IsPaused.Should().BeFalse(); + } + + [Fact] + public async Task Resume_ReleasesEveryWaiterFromTheSamePauseAsync() + { + PackageDownloadPauseController controller = new(); + controller.Pause(); + Task[] waiters = Enumerable.Range(0, 16) + .Select(_ => controller.WaitWhilePausedAsync(CancellationToken.None).AsTask()) + .ToArray(); + + waiters.Should().OnlyContain(waiter => !waiter.IsCompleted); + controller.Resume().Should().BeTrue(); + + await Task.WhenAll(waiters).WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + waiters.Should().OnlyContain(waiter => waiter.IsCompletedSuccessfully); + } + + [Fact] + public async Task CancelingOneWaiter_DoesNotResumeOrCancelOtherWaitersAsync() + { + PackageDownloadPauseController controller = new(); + controller.Pause(); + using CancellationTokenSource cancellation = new(); + Task canceledWaiter = controller.WaitWhilePausedAsync(cancellation.Token).AsTask(); + Task remainingWaiter = controller.WaitWhilePausedAsync(CancellationToken.None).AsTask(); + + await cancellation.CancelAsync(); + + Func canceled = () => canceledWaiter; + await canceled.Should().ThrowAsync(); + controller.IsPaused.Should().BeTrue(); + remainingWaiter.IsCompleted.Should().BeFalse(); + controller.Resume().Should().BeTrue(); + await remainingWaiter.WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task ConcurrentTransitions_ReportExactlyOneStateChangeAsync() + { + PackageDownloadPauseController controller = new(); + + bool[] pauseResults = await Task.WhenAll( + Enumerable.Range(0, 32).Select(_ => Task.Run(controller.Pause))); + bool[] resumeResults = await Task.WhenAll( + Enumerable.Range(0, 32).Select(_ => Task.Run(controller.Resume))); + + pauseResults.Should().ContainSingle(changed => changed); + resumeResults.Should().ContainSingle(changed => changed); + controller.IsPaused.Should().BeFalse(); + } +} diff --git a/GenLauncherGO.Tests/GenLauncherGO.Tests.csproj b/GenLauncherGO.Tests/GenLauncherGO.Tests.csproj new file mode 100644 index 00000000..828359c3 --- /dev/null +++ b/GenLauncherGO.Tests/GenLauncherGO.Tests.csproj @@ -0,0 +1,59 @@ + + + Exe + net10.0-windows + false + + + + + + + + + + + + + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + 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..e78bfdaf --- /dev/null +++ b/GenLauncherGO.Tests/GlobalUsings.cs @@ -0,0 +1,4 @@ +global using FluentAssertions; +global using GenLauncherGO.Tests.Testing; +global using NSubstitute; +global using Xunit; diff --git a/GenLauncherGO.Tests/Infrastructure/Archives/ArchiveExtractorTests.cs b/GenLauncherGO.Tests/Infrastructure/Archives/ArchiveExtractorTests.cs new file mode 100644 index 00000000..1d4d836d --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Archives/ArchiveExtractorTests.cs @@ -0,0 +1,262 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Text; +using System.Threading; +using GenLauncherGO.Infrastructure.Archives; +using SharpCompress.Common; +using SharpCompress.Writers; +using SharpCompress.Writers.SevenZip; + +namespace GenLauncherGO.Tests.Infrastructure.Archives; + +public sealed class ArchiveExtractorTests +{ + [Fact] + public void ExtractToDirectory_PreservesBigFilesByDefault() + { + using TestDirectory directory = new(); + string archivePath = directory.GetPath("mod.zip"); + string destinationDirectory = directory.GetPath("extract"); + CreateZipArchive(archivePath, "Data/test.big", "test data"); + + var extractor = new ArchiveExtractor(); + + extractor.ExtractToDirectory(archivePath, destinationDirectory, cancellationToken: TestContext.Current.CancellationToken); + + File.Exists(Path.Combine(destinationDirectory, "Data", "test.big")).Should().BeTrue(); + File.Exists(Path.Combine(destinationDirectory, "Data", "test.gib")).Should().BeFalse(); + } + + [Fact] + public void ExtractToDirectory_ConvertsBigFilesWhenRequested() + { + using TestDirectory directory = new(); + string archivePath = directory.GetPath("mod.zip"); + string destinationDirectory = directory.GetPath("extract"); + CreateZipArchive(archivePath, "Data/test.big", "test data"); + + var extractor = new ArchiveExtractor(); + + extractor.ExtractToDirectory(archivePath, destinationDirectory, true, TestContext.Current.CancellationToken); + + File.Exists(Path.Combine(destinationDirectory, "Data", "test.gib")).Should().BeTrue(); + File.Exists(Path.Combine(destinationDirectory, "Data", "test.big")).Should().BeFalse(); + } + + /// + /// Archive entry keys are attacker-controlled, so traversal through either separator and a rooted key all have + /// to be refused before anything is written. + /// + [Theory] + [InlineData("../escape.txt", @"..\escape.txt")] + [InlineData(@"..\escape.txt", @"..\escape.txt")] + [InlineData(@"C:\escape.txt", @"C:\escape.txt")] + public void ExtractToDirectory_RejectsEntriesOutsideDestinationDirectory( + string entryKey, + string escapeTargetPath) + { + using TestDirectory directory = new(); + string archivePath = directory.GetPath("mod.zip"); + string destinationDirectory = directory.GetPath("extract"); + string escapedFilePath = Path.GetFullPath(Path.Combine(destinationDirectory, escapeTargetPath)); + CreateZipArchive(archivePath, entryKey, "escaped"); + + var extractor = new ArchiveExtractor(); + + Action act = () => extractor.ExtractToDirectory(archivePath, destinationDirectory); + + act.Should().Throw() + .WithMessage("*outside the destination folder*"); + File.Exists(escapedFilePath).Should().BeFalse(); + } + + [Fact] + public void ExtractToDirectory_EntryWithoutFileName_RejectsArchive() + { + using TestDirectory directory = new(); + string archivePath = directory.GetPath("mod.zip"); + string destinationDirectory = directory.GetPath("extract"); + CreateZipArchive(archivePath, " ", "payload"); + + var extractor = new ArchiveExtractor(); + + Action act = () => extractor.ExtractToDirectory(archivePath, destinationDirectory); + + act.Should().Throw() + .WithMessage("*missing a file name*"); + } + + [Fact] + public void ExtractToDirectory_ExtractsSevenZipArchiveAndConvertsBigFiles() + { + using TestDirectory directory = new(); + string archivePath = directory.GetPath("mod.7z"); + string destinationDirectory = directory.GetPath("extract"); + CreateSevenZipArchive(archivePath, "Data/test.big", "test data"); + + var extractor = new ArchiveExtractor(); + + extractor.ExtractToDirectory(archivePath, destinationDirectory, true, TestContext.Current.CancellationToken); + + File.ReadAllText(Path.Combine(destinationDirectory, "Data", "test.gib")) + .Should().Be("test data"); + File.Exists(Path.Combine(destinationDirectory, "Data", "test.big")).Should().BeFalse(); + } + + [Fact] + public void ExtractToDirectory_RejectsLinkedDestinationTreeWithoutWritingThroughIt() + { + using TestDirectory directory = new(); + string archivePath = directory.GetPath("mod.zip"); + string destinationDirectory = directory.CreateDirectory("extract"); + string linkedDirectory = Path.Combine(destinationDirectory, "linked"); + CreateZipArchive(archivePath, "linked/escape.txt", "escaped"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + linkedDirectory); + var extractor = new ArchiveExtractor(); + + Action act = () => extractor.ExtractToDirectory(archivePath, destinationDirectory); + + act.Should().Throw() + .WithMessage("*reparse point*"); + File.Exists(Path.Combine(junction.TargetDirectory, "escape.txt")).Should().BeFalse(); + Directory.Exists(linkedDirectory).Should().BeTrue(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void ExtractToDirectory_RejectsDestinationBelowLinkedParentBeforeCreatingIt() + { + using TestDirectory directory = new(); + string archivePath = directory.GetPath("mod.zip"); + string linkedParent = directory.GetPath("linked"); + string destinationDirectory = Path.Combine(linkedParent, "extract"); + CreateZipArchive(archivePath, "readme.txt", "payload"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + linkedParent); + var extractor = new ArchiveExtractor(); + + Action act = () => extractor.ExtractToDirectory(archivePath, destinationDirectory); + + act.Should().Throw() + .WithMessage("*reparse point*"); + Directory.Exists(Path.Combine(junction.TargetDirectory, "extract")).Should().BeFalse(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void ExtractToDirectory_RejectsUnrelatedLinkedChildBeforeWritingSafeEntry() + { + using TestDirectory directory = new(); + string archivePath = directory.GetPath("mod.zip"); + string destinationDirectory = directory.CreateDirectory("extract"); + CreateZipArchive(archivePath, "readme.txt", "payload"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + Path.Combine(destinationDirectory, "unrelated")); + var extractor = new ArchiveExtractor(); + + Action act = () => extractor.ExtractToDirectory(archivePath, destinationDirectory); + + act.Should().Throw() + .WithMessage("*reparse point*"); + File.Exists(Path.Combine(destinationDirectory, "readme.txt")).Should().BeFalse(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void ExtractToDirectory_PreCanceledTokenDoesNotWriteArchiveEntries() + { + using TestDirectory directory = new(); + string archivePath = directory.GetPath("mod.zip"); + string destinationDirectory = directory.GetPath("extract"); + CreateZipArchive(archivePath, "readme.txt", "payload"); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + var extractor = new ArchiveExtractor(); + + Action act = () => extractor.ExtractToDirectory( + archivePath, + destinationDirectory, + cancellationToken: cancellation.Token); + + act.Should().Throw(); + File.Exists(Path.Combine(destinationDirectory, "readme.txt")).Should().BeFalse(); + } + + [Fact] + public void ExtractToDirectory_HandlesExplicitDirectoryEntryAndOverwritesExistingFile() + { + using TestDirectory directory = new(); + string archivePath = directory.GetPath("mod.zip"); + string destinationDirectory = directory.CreateDirectory("extract"); + string destinationFilePath = Path.Combine(destinationDirectory, "Data", "readme.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(destinationFilePath)!); + File.WriteAllText(destinationFilePath, "old contents"); + using (ZipArchive archive = ZipFile.Open(archivePath, ZipArchiveMode.Create)) + { + archive.CreateEntry("Data/"); + ZipArchiveEntry entry = archive.CreateEntry("Data/readme.txt"); + using Stream entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream); + writer.Write("replacement"); + } + + var extractor = new ArchiveExtractor(); + + extractor.ExtractToDirectory(archivePath, destinationDirectory, cancellationToken: TestContext.Current.CancellationToken); + + File.ReadAllText(destinationFilePath).Should().Be("replacement"); + } + + /// + /// Extracted package files keep the modification time the archive recorded. Deployment copies that timestamp + /// onto the game-facing file and the modification image cache keys on it, so stamping extraction time instead + /// would rewrite both. + /// + [Fact] + public void ExtractToDirectory_PreservesTheArchiveEntryTimestamp() + { + using TestDirectory directory = new(); + string archivePath = directory.GetPath("mod.zip"); + string destinationDirectory = directory.GetPath("extract"); + DateTime entryTimestamp = new(2020, 1, 2, 3, 4, 4, DateTimeKind.Unspecified); + using (ZipArchive archive = ZipFile.Open(archivePath, ZipArchiveMode.Create)) + { + ZipArchiveEntry entry = archive.CreateEntry("Data/readme.txt"); + entry.LastWriteTime = new DateTimeOffset(entryTimestamp, TimeSpan.Zero); + using Stream entryStream = entry.Open(); + using var writer = new StreamWriter(entryStream); + writer.Write("payload"); + } + + var extractor = new ArchiveExtractor(); + + extractor.ExtractToDirectory(archivePath, destinationDirectory, cancellationToken: TestContext.Current.CancellationToken); + + File.GetLastWriteTime(Path.Combine(destinationDirectory, "Data", "readme.txt")) + .Should().Be(entryTimestamp); + } + + 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); + } + + private static void CreateSevenZipArchive(string archivePath, string entryName, string contents) + { + using IWriter writer = WriterFactory.OpenWriter( + archivePath, + ArchiveType.SevenZip, + new SevenZipWriterOptions()); + using MemoryStream contentsStream = new(Encoding.UTF8.GetBytes(contents)); + writer.Write(entryName, contentsStream, null); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Common/BigFileVariantPathTests.cs b/GenLauncherGO.Tests/Infrastructure/Common/BigFileVariantPathTests.cs new file mode 100644 index 00000000..3a51d2ec --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Common/BigFileVariantPathTests.cs @@ -0,0 +1,133 @@ +using System.IO; +using GenLauncherGO.Infrastructure.Common; + +namespace GenLauncherGO.Tests.Infrastructure.Common; + +public sealed class BigFileVariantPathTests +{ + [Fact] + public void VariantMappings_RoundTripCaseInsensitivePackagePathsAndPreserveOtherFiles() + { + string bigPath = Path.Combine("Data", "asset.BIG"); + string installedPath = BigFileVariantPath.GetInstalledPath(bigPath); + + installedPath.Should().Be(Path.Combine("Data", "asset.gib")); + BigFileVariantPath.GetDeploymentPath(installedPath) + .Should().Be(Path.Combine("Data", "asset.big")); + BigFileVariantPath.GetInstalledPath("readme.txt").Should().Be("readme.txt"); + BigFileVariantPath.GetDeploymentPath("readme.txt").Should().Be("readme.txt"); + } + + [Fact] + public void GetExistingDownloadedPath_PrefersRequestedBigPath() + { + 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"); + + string existingPath = BigFileVariantPath.GetExistingDownloadedPath(bigPath); + + existingPath.Should().Be(bigPath); + } + + [Fact] + public void GetExistingDownloadedPath_FallsBackToConvertedGibPath() + { + using TestDirectory testDirectory = new(); + string bigPath = Path.Combine(testDirectory.Path, "asset.big"); + string gibPath = Path.Combine(testDirectory.Path, "asset.gib"); + File.WriteAllText(gibPath, "gib"); + + string existingPath = BigFileVariantPath.GetExistingDownloadedPath(bigPath); + + existingPath.Should().Be(gibPath); + } + + [Fact] + public void ConvertBigFileToGib_MovesBigFileAndReplacesExistingGib() + { + 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"); + + BigFileVariantPath.ConvertBigFileToGib(bigPath); + + File.Exists(bigPath).Should().BeFalse(); + File.ReadAllText(gibPath).Should().Be("new"); + } + + [Fact] + public void PrepareBigFileResumePath_MovesConvertedGibBackToBigPath() + { + using TestDirectory testDirectory = new(); + string bigPath = Path.Combine(testDirectory.Path, "asset.big"); + string gibPath = Path.Combine(testDirectory.Path, "asset.gib"); + File.WriteAllText(gibPath, "partial"); + + BigFileVariantPath.PrepareBigFileResumePath(bigPath); + + File.ReadAllText(bigPath).Should().Be("partial"); + File.Exists(gibPath).Should().BeFalse(); + } + + /// + /// A partial .big download outranks an installed .gib of the same package, because moving the + /// installed file over it would discard the bytes the resumed transfer is about to append to. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PrepareBigFileResumePath_ReturnsWhenResumeMoveIsNotNeeded(bool installedFileAlsoExists) + { + using TestDirectory testDirectory = new(); + string bigPath = Path.Combine(testDirectory.Path, "asset.big"); + string gibPath = Path.Combine(testDirectory.Path, "asset.gib"); + File.WriteAllText(bigPath, "partial"); + if (installedFileAlsoExists) + { + File.WriteAllText(gibPath, "installed"); + } + + BigFileVariantPath.PrepareBigFileResumePath(bigPath); + + File.ReadAllText(bigPath).Should().Be("partial"); + File.Exists(gibPath).Should().Be(installedFileAlsoExists); + } + + [Fact] + public void BigFileConversions_NonPackageFile_LeaveTheFileUntouched() + { + using TestDirectory testDirectory = new(); + string filePath = Path.Combine(testDirectory.Path, "readme.txt"); + File.WriteAllText(filePath, "readme"); + + BigFileVariantPath.ConvertBigFileToGib(filePath); + BigFileVariantPath.PrepareBigFileResumePath(filePath); + + File.ReadAllText(filePath).Should().Be("readme"); + Directory.EnumerateFileSystemEntries(testDirectory.Path).Should().ContainSingle() + .Which.Should().Be(filePath); + } + + /// + /// The .gib rename belongs to packages alone. A same-named installed package beside a non-package + /// download must not be dragged into a resume it has nothing to do with. + /// + [Fact] + public void PrepareBigFileResumePath_NonPackageFile_LeavesTheInstalledPackageAlone() + { + using TestDirectory testDirectory = new(); + string textPath = Path.Combine(testDirectory.Path, "readme.txt"); + string gibPath = Path.Combine(testDirectory.Path, "readme.gib"); + File.WriteAllText(gibPath, "installed"); + + BigFileVariantPath.PrepareBigFileResumePath(textPath); + + File.ReadAllText(gibPath).Should().Be("installed"); + File.Exists(textPath).Should().BeFalse(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Common/FileSystemPathSafetyTests.cs b/GenLauncherGO.Tests/Infrastructure/Common/FileSystemPathSafetyTests.cs new file mode 100644 index 00000000..fb30b42f --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Common/FileSystemPathSafetyTests.cs @@ -0,0 +1,198 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GenLauncherGO.Infrastructure.Common; + +namespace GenLauncherGO.Tests.Infrastructure.Common; + +public sealed class FileSystemPathSafetyTests +{ + // Callers name the paths they guard and the checks build the sentence, so these assertions pin the generated + // wording rather than a message the test supplied. + private const string PathSubject = "Test paths"; + + private const string OwnerDescription = "the owned root"; + + private const string LinkedMessage = "Test paths must not contain reparse points."; + + [Fact] + public void ResolveOwnedSubpath_ReturnsNormalizedChildPath() + { + using TestDirectory directory = new(); + string candidatePath = Path.Combine(directory.Path, "Child", "..", "Child", "file.txt"); + + string result = FileSystemPathSafety.ResolveOwnedSubpath( + directory.Path, + candidatePath, + PathSubject, + OwnerDescription); + + result.Should().Be(Path.GetFullPath(Path.Combine(directory.Path, "Child", "file.txt"))); + } + + [Fact] + public void ResolveOwnedSubpath_RejectsPathOutsideOwnedRoot() + { + using TestDirectory directory = new(); + string outsidePath = Path.Combine(directory.Path, "..", "outside.txt"); + + Action act = () => FileSystemPathSafety.ResolveOwnedSubpath( + directory.Path, + outsidePath, + PathSubject, + OwnerDescription); + + act.Should().Throw() + .WithMessage("Test paths must stay inside the owned root."); + } + + [Fact] + public void ResolveOwnedSubpath_LinkedOwnedRoot_RejectsPath() + { + using TestDirectory directory = new(); + string linkedRoot = Path.Combine(directory.Path, "LinkedRoot"); + string targetPath = Path.Combine(directory.Path, "Target"); + ReparsePointTestSupport.CreateDirectoryJunction(linkedRoot, targetPath); + + Action act = () => FileSystemPathSafety.ResolveOwnedSubpath( + linkedRoot, + Path.Combine(linkedRoot, "file.txt"), + PathSubject, + OwnerDescription); + + act.Should().Throw() + .WithMessage(LinkedMessage); + + Directory.Delete(linkedRoot, false); + } + + [Fact] + public void ResolveOwnedSubpath_RejectsLinkedCandidateAncestor() + { + using TestDirectory directory = new(); + string targetPath = Path.Combine(directory.Path, "Target"); + string linkPath = Path.Combine(directory.Path, "Linked"); + ReparsePointTestSupport.CreateDirectoryJunction(linkPath, targetPath); + + Action act = () => FileSystemPathSafety.ResolveOwnedSubpath( + directory.Path, + Path.Combine(linkPath, "file.txt"), + PathSubject, + OwnerDescription); + + act.Should().Throw() + .WithMessage(LinkedMessage); + FileSystemPathSafety.IsReparsePoint(linkPath).Should().BeTrue(); + + Directory.Delete(linkPath, false); + } + + [Fact] + public void ExistingPathChainContainsReparsePoint_ReturnsFalseForRootAndMissingChild() + { + using TestDirectory directory = new(); + string missingChild = Path.Combine(directory.Path, "Missing", "file.txt"); + + FileSystemPathSafety.ExistingPathChainContainsReparsePoint( + Path.GetPathRoot(directory.Path)!, + PathSubject).Should().BeFalse(); + FileSystemPathSafety.ExistingPathChainContainsReparsePoint( + missingChild, + PathSubject).Should().BeFalse(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DirectoryTreeApis_RootReparsePoint_RejectTree(bool enumerateFiles) + { + using TestDirectory directory = new(); + string linkedRoot = Path.Combine(directory.Path, "LinkedRoot"); + string targetPath = Path.Combine(directory.Path, "Target"); + ReparsePointTestSupport.CreateDirectoryJunction(linkedRoot, targetPath); + + Action act = enumerateFiles + ? () => _ = FileSystemPathSafety.GetDirectoryFilesWithNoReparsePoints(linkedRoot, PathSubject) + : () => FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints(linkedRoot, PathSubject); + + act.Should().Throw() + .WithMessage(LinkedMessage); + } + + [Fact] + public void EnsureDirectoryTreeHasNoReparsePoints_ChildReparsePoint_RejectsPath() + { + 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); + ReparsePointTestSupport.CreateDirectoryJunction(linkPath, linkedTarget); + + Action act = () => FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints(rootPath, PathSubject); + + act.Should().Throw() + .WithMessage(LinkedMessage); + } + + [Fact] + public void EnsureDirectoryTreeHasNoReparsePoints_NestedReparsePoint_RejectsPath() + { + using TestDirectory directory = new(); + string rootPath = Path.Combine(directory.Path, "Root"); + string nestedPath = Path.Combine(rootPath, "Nested"); + string targetPath = Path.Combine(directory.Path, "Target"); + string linkPath = Path.Combine(nestedPath, "Linked"); + Directory.CreateDirectory(nestedPath); + ReparsePointTestSupport.CreateDirectoryJunction(linkPath, targetPath); + + Action act = () => FileSystemPathSafety.EnsureDirectoryTreeHasNoReparsePoints(rootPath, PathSubject); + + act.Should().Throw() + .WithMessage(LinkedMessage); + } + + [Fact] + public void GetDirectoryFilesWithNoReparsePoints_ReturnsNestedFiles() + { + using TestDirectory directory = new(); + string firstFilePath = Path.Combine(directory.Path, "first.txt"); + string secondFilePath = Path.Combine(directory.Path, "Nested", "second.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(secondFilePath)!); + File.WriteAllText(firstFilePath, "first"); + File.WriteAllText(secondFilePath, "second"); + + IReadOnlyList result = FileSystemPathSafety.GetDirectoryFilesWithNoReparsePoints( + directory.Path, + PathSubject); + + result.Should().BeEquivalentTo(firstFilePath, secondFilePath); + } + + [Fact] + public void GetDirectoryFilesWithNoReparsePoints_ChildReparsePoint_RejectsTree() + { + using TestDirectory directory = new(); + string rootPath = Path.Combine(directory.Path, "Root"); + string linkedTarget = Path.Combine(directory.Path, "Target"); + Directory.CreateDirectory(rootPath); + ReparsePointTestSupport.CreateDirectoryJunction(Path.Combine(rootPath, "Linked"), linkedTarget); + + Action act = () => FileSystemPathSafety.GetDirectoryFilesWithNoReparsePoints(rootPath, PathSubject); + + act.Should().Throw() + .WithMessage(LinkedMessage); + } + + [Fact] + public void CreateRecursiveNoLinksOptions_SkipsReparsePoints() + { + EnumerationOptions result = FileSystemPathSafety.CreateRecursiveNoLinksOptions(); + + result.AttributesToSkip.Should().Be(FileAttributes.ReparsePoint); + result.IgnoreInaccessible.Should().BeFalse(); + result.RecurseSubdirectories.Should().BeTrue(); + result.ReturnSpecialDirectories.Should().BeFalse(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Common/ManifestPathResolverTests.cs b/GenLauncherGO.Tests/Infrastructure/Common/ManifestPathResolverTests.cs new file mode 100644 index 00000000..7c6bf035 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Common/ManifestPathResolverTests.cs @@ -0,0 +1,48 @@ +using System; +using System.IO; +using GenLauncherGO.Infrastructure.Common; + +namespace GenLauncherGO.Tests.Infrastructure.Common; + +public sealed class ManifestPathResolverTests +{ + [Theory] + [InlineData("Data/INI/GameData.ini")] + [InlineData(@"Data\INI\GameData.ini")] + [InlineData(" Data/INI/GameData.ini ")] + public void ResolvePath_ReturnsFullPathUnderRoot(string manifestFileName) + { + using TestDirectory directory = new(); + + string result = ManifestPathResolver.ResolvePath(directory.Path, manifestFileName); + + result.Should().Be(Path.GetFullPath(Path.Combine(directory.Path, "Data", "INI", "GameData.ini"))); + } + + /// + /// Manifest file names arrive from a remote catalog, and a rooted one resolves against the volume rather than + /// the package folder. A leading separator roots a path just as a drive letter does, so both spellings have to + /// be refused. + /// + [Theory] + [InlineData(@"C:\Package\Data.big")] + [InlineData("C:Package/Data.big")] + [InlineData("/Package/Data.big")] + [InlineData(@"\Package\Data.big")] + [InlineData("../Data.big")] + [InlineData("./Data.big")] + public void NormalizeRelativePath_RejectsUnsafePaths(string manifestFileName) + { + Action act = () => ManifestPathResolver.NormalizeRelativePath(manifestFileName); + + act.Should().Throw(); + } + + [Fact] + public void NormalizeForManifestIndex_UsesSlashSeparators() + { + string result = ManifestPathResolver.NormalizeForManifestIndex(@"Data\INI\GameData.ini"); + + result.Should().Be("Data/INI/GameData.ini"); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Common/OwnedDirectoryTreeTests.cs b/GenLauncherGO.Tests/Infrastructure/Common/OwnedDirectoryTreeTests.cs new file mode 100644 index 00000000..a52353b3 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Common/OwnedDirectoryTreeTests.cs @@ -0,0 +1,624 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Common; + +namespace GenLauncherGO.Tests.Infrastructure.Common; + +public sealed class OwnedDirectoryTreeTests +{ + [Fact] + public void EnsureExists_MissingDirectory_CreatesDirectory() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string contentPath = Path.Combine(ownedRoot, "Content"); + + string result = OwnedDirectoryTree.EnsureExists(ownedRoot, contentPath); + + result.Should().Be(Path.GetFullPath(contentPath)); + Directory.Exists(contentPath).Should().BeTrue(); + } + + [Fact] + public void EnsureExists_FileAtDirectoryPath_Throws() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string contentPath = directory.CreateFile("Owned/Content", "occupied"); + + Action act = () => OwnedDirectoryTree.EnsureExists(ownedRoot, contentPath); + + act.Should().Throw(); + File.ReadAllText(contentPath).Should().Be("occupied"); + } + + [Fact] + public void EnsureExists_LinkedLeaf_RejectsWithoutTouchingTarget() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string linkPath = Path.Combine(ownedRoot, "Version"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + + Action act = () => OwnedDirectoryTree.EnsureExists(ownedRoot, linkPath); + + act.Should().Throw(); + FileSystemPathSafety.IsReparsePoint(linkPath).Should().BeTrue(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void EnsureRealDirectory_LinkedLeaf_ReplacesLinkWithoutTouchingTarget() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string linkPath = Path.Combine(ownedRoot, "Version"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + + string result = OwnedDirectoryTree.EnsureRealDirectory(ownedRoot, linkPath); + + result.Should().Be(Path.GetFullPath(linkPath)); + Directory.Exists(linkPath).Should().BeTrue(); + FileSystemPathSafety.IsReparsePoint(linkPath).Should().BeFalse(); + Directory.EnumerateFileSystemEntries(linkPath).Should().BeEmpty(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void DirectoryPreparation_FileAtLeaf_RejectsWithoutDeletingFile(bool prepareEmpty) + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string contentPath = directory.CreateFile("Owned/Content", "occupied"); + + Action act = prepareEmpty + ? () => OwnedDirectoryTree.PrepareEmpty(ownedRoot, contentPath) + : () => OwnedDirectoryTree.EnsureRealDirectory(ownedRoot, contentPath); + + act.Should().Throw(); + File.ReadAllText(contentPath).Should().Be("occupied"); + } + + [Fact] + public void DeleteIfExists_NestedDirectoryLink_DeletesTreeWithoutTouchingTarget() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string contentPath = directory.CreateDirectory("Owned/Content"); + string linkPath = Path.Combine(contentPath, "Linked"); + directory.CreateFile("Owned/Content/owned.txt", "owned"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + + bool deleted = OwnedDirectoryTree.DeleteIfExists( + new OwnedContentPath(ownedRoot, contentPath)); + + deleted.Should().BeTrue(); + Directory.Exists(contentPath).Should().BeFalse(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void DeleteIfExists_LinkedLeaf_DeletesWithoutTouchingTarget() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string linkPath = Path.Combine(ownedRoot, "Version"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + + bool deleted = OwnedDirectoryTree.DeleteIfExists( + new OwnedContentPath(ownedRoot, linkPath)); + + deleted.Should().BeTrue(); + Directory.Exists(linkPath).Should().BeFalse(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void DeleteIfExists_LinkedAncestor_RejectsWithoutTouchingTarget() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string linkedAncestor = Path.Combine(ownedRoot, "Linked"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + linkedAncestor, + canaryFileName: "Child/target.txt"); + string candidatePath = Path.Combine(linkedAncestor, "Child"); + + Action act = () => OwnedDirectoryTree.DeleteIfExists( + new OwnedContentPath(ownedRoot, candidatePath)); + + act.Should().Throw(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void DeleteIfExists_MissingDirectory_ReturnsFalse() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string missingPath = Path.Combine(ownedRoot, "Missing"); + + bool deleted = OwnedDirectoryTree.DeleteIfExists(ownedRoot, missingPath); + + deleted.Should().BeFalse(); + Directory.Exists(ownedRoot).Should().BeTrue(); + } + + [Fact] + public void PrepareEmpty_PopulatedDirectory_DeletesChildrenWithoutFollowingLinks() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string contentPath = directory.CreateDirectory("Owned/Content"); + string linkPath = Path.Combine(contentPath, "Linked"); + directory.CreateFile("Owned/Content/file.txt", "file"); + directory.CreateFile("Owned/Content/Nested/file.txt", "nested"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + + string result = OwnedDirectoryTree.PrepareEmpty(ownedRoot, contentPath); + + result.Should().Be(Path.GetFullPath(contentPath)); + Directory.Exists(contentPath).Should().BeTrue(); + Directory.EnumerateFileSystemEntries(contentPath).Should().BeEmpty(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void PrepareEmpty_LinkedLeaf_ReplacesLinkWithoutTouchingTarget() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string linkPath = Path.Combine(ownedRoot, "Scratch"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + + string result = OwnedDirectoryTree.PrepareEmpty(ownedRoot, linkPath); + + result.Should().Be(Path.GetFullPath(linkPath)); + FileSystemPathSafety.IsReparsePoint(linkPath).Should().BeFalse(); + Directory.EnumerateFileSystemEntries(linkPath).Should().BeEmpty(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void PrepareEmptyExcept_SelectedChild_PreservesOnlySelectedChild() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string contentPath = directory.CreateDirectory("Owned/Content"); + string preservedPath = directory.CreateDirectory("Owned/Content/Preserved"); + string preservedFilePath = directory.CreateFile("Owned/Content/Preserved/state.json", "state"); + string removedPath = directory.CreateDirectory("Owned/Content/Removed"); + directory.CreateFile("Owned/Content/Removed/file.txt", "removed"); + directory.CreateFile("Owned/Content/transient.txt", "transient"); + + string result = OwnedDirectoryTree.PrepareEmptyExcept(ownedRoot, contentPath, preservedPath); + + result.Should().Be(Path.GetFullPath(contentPath)); + File.ReadAllText(preservedFilePath).Should().Be("state"); + Directory.Exists(removedPath).Should().BeFalse(); + Directory.EnumerateFileSystemEntries(contentPath).Should().ContainSingle() + .Which.Should().Be(preservedPath); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void PrepareEmptyExcept_MissingOrLinkedRoot_PreparesRealEmptyDirectory(bool linkedRoot) + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string contentPath = Path.Combine(ownedRoot, "Content"); + string? canaryFilePath = null; + string? canaryContents = null; + if (linkedRoot) + { + ProtectedJunction junction = + ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, contentPath); + canaryFilePath = junction.CanaryFilePath; + canaryContents = junction.CanaryContents; + } + + string result = OwnedDirectoryTree.PrepareEmptyExcept( + ownedRoot, + contentPath, + Path.Combine(contentPath, "Preserved")); + + result.Should().Be(Path.GetFullPath(contentPath)); + FileSystemPathSafety.IsReparsePoint(contentPath).Should().BeFalse(); + Directory.EnumerateFileSystemEntries(contentPath).Should().BeEmpty(); + if (canaryFilePath is not null) + { + File.ReadAllText(canaryFilePath).Should().Be(canaryContents); + } + } + + [Fact] + public void PrepareEmptyExcept_FileAtRoot_RejectsWithoutDeletingFile() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string contentPath = directory.CreateFile("Owned/Content", "occupied"); + + Action act = () => OwnedDirectoryTree.PrepareEmptyExcept( + ownedRoot, + contentPath, + Path.Combine(contentPath, "Preserved")); + + act.Should().Throw(); + File.ReadAllText(contentPath).Should().Be("occupied"); + } + + [Fact] + public void DeleteEmptyParents_EmptyAncestorChain_DeletesThroughOwnedBoundary() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string parentPath = directory.CreateDirectory("Owned/A/B"); + string childPath = Path.Combine(parentPath, "missing.file"); + + IReadOnlyList deletedPaths = OwnedDirectoryTree.DeleteEmptyParents(ownedRoot, childPath); + + deletedPaths.Should().Equal( + Path.Combine(ownedRoot, "A", "B"), + Path.Combine(ownedRoot, "A")); + Directory.Exists(ownedRoot).Should().BeTrue(); + } + + [Fact] + public void DeleteEmptyParents_NonEmptyAncestor_StopsAfterEmptyChildParent() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string parentPath = directory.CreateDirectory("Owned/A/B"); + string childPath = Path.Combine(parentPath, "missing.file"); + string siblingFilePath = directory.CreateFile("Owned/A/keep.txt", "keep"); + + IReadOnlyList deletedPaths = OwnedDirectoryTree.DeleteEmptyParents(ownedRoot, childPath); + + deletedPaths.Should().Equal(parentPath); + Directory.Exists(Path.Combine(ownedRoot, "A")).Should().BeTrue(); + File.ReadAllText(siblingFilePath).Should().Be("keep"); + } + + [Fact] + public void DeleteEmptyParents_ChildOutsideOwnedRoot_Throws() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string siblingPath = directory.CreateDirectory("Sibling/A"); + string childPath = Path.Combine(siblingPath, "missing.file"); + + Action act = () => OwnedDirectoryTree.DeleteEmptyParents(ownedRoot, childPath); + + act.Should().Throw(); + Directory.Exists(siblingPath).Should().BeTrue(); + } + + [Fact] + public void DeleteEmptyParents_LinkedParent_RejectsWithoutTouchingTarget() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string linkPath = Path.Combine(ownedRoot, "Linked"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + string childPath = Path.Combine(linkPath, "missing.file"); + + Action act = () => OwnedDirectoryTree.DeleteEmptyParents(ownedRoot, childPath); + + act.Should().Throw(); + FileSystemPathSafety.IsReparsePoint(linkPath).Should().BeTrue(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void DeleteEmptyParents_MissingIntermediate_ContinuesToExistingAncestor() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string existingAncestor = directory.CreateDirectory("Owned/A"); + string childPath = Path.Combine(existingAncestor, "Missing", "Nested", "missing.file"); + + IReadOnlyList deletedPaths = OwnedDirectoryTree.DeleteEmptyParents(ownedRoot, childPath); + + deletedPaths.Should().Equal(existingAncestor); + Directory.Exists(existingAncestor).Should().BeFalse(); + Directory.Exists(ownedRoot).Should().BeTrue(); + } + + [Fact] + public void DeleteEmptyParentsIncludingRoot_EmptyAncestorChain_DeletesExclusiveOwnedRoot() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string parentPath = directory.CreateDirectory("Owned/A/B"); + string childPath = Path.Combine(parentPath, "missing.file"); + + IReadOnlyList deletedPaths = OwnedDirectoryTree.DeleteEmptyParentsIncludingRoot( + ownedRoot, + childPath); + + deletedPaths.Should().Equal( + Path.Combine(ownedRoot, "A", "B"), + Path.Combine(ownedRoot, "A"), + ownedRoot); + Directory.Exists(ownedRoot).Should().BeFalse(); + } + + [Fact] + public void DeleteEmptyParentsIncludingRoot_NonEmptyRoot_PreservesRoot() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string parentPath = directory.CreateDirectory("Owned/A/B"); + string childPath = Path.Combine(parentPath, "missing.file"); + string retainedFilePath = directory.CreateFile("Owned/retained.txt", "retained"); + + IReadOnlyList deletedPaths = OwnedDirectoryTree.DeleteEmptyParentsIncludingRoot( + ownedRoot, + childPath); + + deletedPaths.Should().Equal( + Path.Combine(ownedRoot, "A", "B"), + Path.Combine(ownedRoot, "A")); + File.ReadAllText(retainedFilePath).Should().Be("retained"); + } + + [Fact] + public void DeleteEmptyDirectories_MixedTree_DeletesOnlyEmptyDirectories() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string contentPath = directory.CreateDirectory("Owned/Content"); + string emptyPath = directory.CreateDirectory("Owned/Content/Empty/Nested"); + string retainedFilePath = directory.CreateFile("Owned/Content/Retained/file.txt", "keep"); + string linkPath = Path.Combine(contentPath, "Linked"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + string targetEmptyPath = directory.CreateDirectory("ExternalTarget/Empty"); + + bool rootDeleted = OwnedDirectoryTree.DeleteEmptyDirectories( + new OwnedContentPath(ownedRoot, contentPath)); + + rootDeleted.Should().BeFalse(); + Directory.Exists(emptyPath).Should().BeFalse(); + File.ReadAllText(retainedFilePath).Should().Be("keep"); + FileSystemPathSafety.IsReparsePoint(linkPath).Should().BeTrue(); + Directory.Exists(contentPath).Should().BeTrue(); + Directory.Exists(targetEmptyPath).Should().BeTrue(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void DeleteEmptyDirectories_EntireTreeIsEmpty_DeletesRoot() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string contentPath = directory.CreateDirectory("Owned/Content/Empty/Nested"); + string treeRoot = Path.Combine(ownedRoot, "Content"); + + bool rootDeleted = OwnedDirectoryTree.DeleteEmptyDirectories( + new OwnedContentPath(ownedRoot, treeRoot)); + + rootDeleted.Should().BeTrue(); + Directory.Exists(contentPath).Should().BeFalse(); + Directory.Exists(treeRoot).Should().BeFalse(); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(true, true)] + public void DeleteEmptyDirectories_MissingFileOrLinkedRoot_ReturnsFalseWithoutDeletingTarget( + bool pathExists, + bool linkedRoot) + { + using TestDirectory directory = new(); + (string ownedRoot, string treeRoot, string? protectedFilePath, string? protectedContents) = + CreateInvalidTreeRoot(directory, pathExists, linkedRoot); + + bool deleted = OwnedDirectoryTree.DeleteEmptyDirectories( + new OwnedContentPath(ownedRoot, treeRoot)); + + deleted.Should().BeFalse(); + if (protectedFilePath is not null) + { + File.ReadAllText(protectedFilePath).Should().Be(protectedContents); + } + } + + [Fact] + public void DeleteReparsePoints_NestedLink_DeletesLinkWithoutTouchingTarget() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string contentPath = directory.CreateDirectory("Owned/Content/Nested"); + string treeRoot = Path.Combine(ownedRoot, "Content"); + string linkPath = Path.Combine(contentPath, "Linked"); + string retainedFilePath = directory.CreateFile("Owned/Content/Nested/retained.txt", "retained"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + + IReadOnlyList deletedPaths = OwnedDirectoryTree.DeleteReparsePoints( + new OwnedContentPath(ownedRoot, treeRoot)); + + deletedPaths.Should().Equal(linkPath); + Directory.Exists(linkPath).Should().BeFalse(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + File.ReadAllText(retainedFilePath).Should().Be("retained"); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(true, true)] + public void DeleteReparsePoints_MissingFileOrLinkedRoot_RejectsWithoutDeletingTarget( + bool pathExists, + bool linkedRoot) + { + using TestDirectory directory = new(); + (string ownedRoot, string treeRoot, string? protectedFilePath, string? protectedContents) = + CreateInvalidTreeRoot(directory, pathExists, linkedRoot); + + Action act = () => OwnedDirectoryTree.DeleteReparsePoints( + new OwnedContentPath(ownedRoot, treeRoot)); + + act.Should().Throw(); + if (protectedFilePath is not null) + { + File.ReadAllText(protectedFilePath).Should().Be(protectedContents); + } + } + + /// + /// A missing leaf is created, so a linked ancestor has to be refused before that: creating the leaf would + /// place a launcher directory inside whatever the link resolves to. + /// + [Fact] + public void EnsureRealDirectory_LinkedParent_RejectsWithoutCreatingThroughTheLink() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string linkPath = Path.Combine(ownedRoot, "Linked"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + string contentPath = Path.Combine(linkPath, "Content"); + + Action act = () => OwnedDirectoryTree.EnsureRealDirectory(ownedRoot, contentPath); + + act.Should().Throw(); + Directory.Exists(Path.Combine(junction.TargetDirectory, "Content")).Should().BeFalse(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void PrepareEmpty_MissingDirectoryUnderLinkedParent_RejectsWithoutCreatingIt() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string linkPath = Path.Combine(ownedRoot, "Linked"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + string contentPath = Path.Combine(linkPath, "Content"); + + Action act = () => OwnedDirectoryTree.PrepareEmpty(ownedRoot, contentPath); + + act.Should().Throw(); + Directory.Exists(Path.Combine(junction.TargetDirectory, "Content")).Should().BeFalse(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + /// + /// A file reached through a link is deleted as a plain file, so the ancestor check is the only thing between + /// the delete and a file the launcher does not own. + /// + [Fact] + public void DeleteIfExists_FileUnderLinkedAncestor_RejectsWithoutDeletingIt() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string linkPath = Path.Combine(ownedRoot, "Linked"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + string candidatePath = Path.Combine(linkPath, "target.txt"); + + Action act = () => OwnedDirectoryTree.DeleteIfExists(ownedRoot, candidatePath); + + act.Should().Throw(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + /// + /// Pruning walks upward and deletes as it goes, so an owned root that is itself a link would hand the walk a + /// directory tree that belongs to somebody else. + /// + [Fact] + public void DeleteEmptyParents_LinkedOwnedRoot_RejectsWithoutDeletingThroughTheLink() + { + using TestDirectory directory = new(); + string linkedRoot = directory.GetPath("Owned"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkedRoot); + string emptyParentPath = directory.CreateDirectory("ExternalTarget/A"); + string childPath = Path.Combine(linkedRoot, "A", "missing.file"); + + Action act = () => OwnedDirectoryTree.DeleteEmptyParents(linkedRoot, childPath); + + act.Should().Throw(); + Directory.Exists(emptyParentPath).Should().BeTrue(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + /// + /// Pruning is cleanup that runs after a failure, so an unexpected file where a directory was recorded has to + /// stop the walk rather than fail the cleanup. + /// + [Fact] + public void DeleteEmptyParents_FileInAncestorChain_StopsWithoutDeletingIt() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateDirectory("Owned"); + string filePath = directory.CreateFile("Owned/A", "occupied"); + string childPath = Path.Combine(filePath, "missing.file"); + + IReadOnlyList deletedPaths = OwnedDirectoryTree.DeleteEmptyParents(ownedRoot, childPath); + + deletedPaths.Should().BeEmpty(); + File.ReadAllText(filePath).Should().Be("occupied"); + } + + [Fact] + public void DeleteEmptyParentsIncludingRoot_FileAtOwnedRoot_LeavesItUntouched() + { + using TestDirectory directory = new(); + string ownedRoot = directory.CreateFile("Owned", "occupied"); + string childPath = Path.Combine(ownedRoot, "missing.file"); + + IReadOnlyList deletedPaths = OwnedDirectoryTree.DeleteEmptyParentsIncludingRoot( + ownedRoot, + childPath); + + deletedPaths.Should().BeEmpty(); + File.ReadAllText(ownedRoot).Should().Be("occupied"); + } + + /// + /// This overload is handed a launcher-owned root such as the package backup folder. A link in its place has to + /// be reported, because answering "nothing was empty" would let the caller believe the folder was inspected. + /// + [Fact] + public void DeleteEmptyDirectories_LinkedOwnedRoot_RejectsWithoutTouchingTarget() + { + using TestDirectory directory = new(); + string linkedRoot = directory.GetPath("Owned"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkedRoot); + string emptyChildPath = directory.CreateDirectory("ExternalTarget/Empty"); + + Action act = () => OwnedDirectoryTree.DeleteEmptyDirectories(linkedRoot); + + act.Should().Throw(); + Directory.Exists(emptyChildPath).Should().BeTrue(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + private static (string OwnedRoot, string TreeRoot, string? ProtectedFilePath, string? ProtectedContents) + CreateInvalidTreeRoot( + TestDirectory directory, + bool pathExists, + bool linkedRoot) + { + string ownedRoot = directory.CreateDirectory("Owned"); + string treeRoot = Path.Combine(ownedRoot, "Content"); + if (!pathExists) + { + return (ownedRoot, treeRoot, null, null); + } + + if (!linkedRoot) + { + directory.CreateFile("Owned/Content", "occupied"); + return (ownedRoot, treeRoot, treeRoot, "occupied"); + } + + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, treeRoot); + return (ownedRoot, treeRoot, junction.CanaryFilePath, junction.CanaryContents); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Common/PhysicalDirectoryPathTests.cs b/GenLauncherGO.Tests/Infrastructure/Common/PhysicalDirectoryPathTests.cs new file mode 100644 index 00000000..b82d2bc9 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Common/PhysicalDirectoryPathTests.cs @@ -0,0 +1,212 @@ +using System; +using System.IO; +using System.Linq; +using GenLauncherGO.Infrastructure.Common; + +namespace GenLauncherGO.Tests.Infrastructure.Common; + +/// +/// The launcher decides whether two paths name the same game folder, and whether a recorded deployment still +/// describes the folder in front of it, from these two answers. Both have to see through the aliases a path can be +/// spelled with instead of comparing the spelling itself. +/// +public sealed class PhysicalDirectoryPathTests +{ + [Fact] + public void ResolveExisting_JunctionPath_ReturnsTheCanonicalTargetPath() + { + using TestDirectory directory = new(); + string targetDirectory = directory.CreateDirectory("Target"); + string junctionPath = directory.GetPath("Link"); + ReparsePointTestSupport.CreateDirectoryJunction(junctionPath, targetDirectory); + + string result = PhysicalDirectoryPath.ResolveExisting(junctionPath); + + result.Should().Be(PhysicalDirectoryPath.ResolveExisting(targetDirectory)); + Path.GetFileName(result).Should().Be("Target"); + } + + /// + /// The canonical path is compared against ordinary normalized paths and stored in the deployment manifest, so a + /// device prefix or a trailing separator would silently break every one of those comparisons. + /// + [Fact] + public void ResolveExisting_ReturnsAPlainPathWithoutADevicePrefixOrTrailingSeparator() + { + using TestDirectory directory = new(); + string targetDirectory = directory.CreateDirectory("Target"); + + string result = PhysicalDirectoryPath.ResolveExisting(targetDirectory); + + result.Should().NotStartWith(@"\\?\"); + Path.EndsInDirectorySeparator(result).Should().BeFalse(); + Directory.Exists(result).Should().BeTrue(); + } + + /// + /// A caller may already hold the extended-length spelling of a path, which must not be prefixed a second time. + /// + [Fact] + public void ResolveExisting_ExtendedLengthSpelling_ReturnsTheSameCanonicalPath() + { + using TestDirectory directory = new(); + string targetDirectory = directory.CreateDirectory("Target"); + + string result = PhysicalDirectoryPath.ResolveExisting(@"\\?\" + Path.GetFullPath(targetDirectory)); + + result.Should().Be(PhysicalDirectoryPath.ResolveExisting(targetDirectory)); + } + + /// + /// Game and launcher folders nest deeply enough to outgrow the first buffer the resolver asks Windows to fill, + /// and a truncated canonical path would name a different directory than the one that was opened. + /// + [Fact] + public void ResolveExisting_PathLongerThanTheInitialBuffer_ReturnsTheCompletePath() + { + using TestDirectory directory = new(); + string[] segments = [.. Enumerable.Repeat(new string('d', 80), 7)]; + string deepDirectory = directory.CreateDirectory(string.Join('/', segments)); + + string result = PhysicalDirectoryPath.ResolveExisting(deepDirectory); + + result.Should().Be(Path.Combine( + PhysicalDirectoryPath.ResolveExisting(directory.Path), + Path.Combine(segments))); + result.Length.Should().BeGreaterThan(512); + } + + [Fact] + public void ResolveExisting_MissingDirectory_Throws() + { + using TestDirectory directory = new(); + string missingPath = directory.GetPath("Missing"); + + Action act = () => PhysicalDirectoryPath.ResolveExisting(missingPath); + + act.Should().Throw(); + } + + [Fact] + public void ResolveExisting_FilePath_Throws() + { + using TestDirectory directory = new(); + string filePath = directory.CreateFile("game.exe", "binary"); + + Action act = () => PhysicalDirectoryPath.ResolveExisting(filePath); + + act.Should().Throw(); + } + + /// + /// Identity is what tells the launcher a recorded game folder is still the same folder after it was reached by + /// another name, so an alias has to answer with the identity of what it resolves to. + /// + [Fact] + public void GetIdentity_JunctionPath_ReturnsTheTargetIdentity() + { + using TestDirectory directory = new(); + string targetDirectory = directory.CreateDirectory("Target"); + string junctionPath = directory.GetPath("Link"); + ReparsePointTestSupport.CreateDirectoryJunction(junctionPath, targetDirectory); + + PhysicalFileSystemIdentity result = PhysicalDirectoryPath.GetIdentity(junctionPath); + + result.Should().Be(PhysicalDirectoryPath.GetIdentity(targetDirectory)); + } + + /// + /// Two folders on one volume share a volume serial number and must still be told apart, which is the whole + /// reason the file index is part of the identity. + /// + [Fact] + public void GetIdentity_DifferentDirectories_ReturnsDifferentIdentities() + { + using TestDirectory directory = new(); + string firstDirectory = directory.CreateDirectory("First"); + string secondDirectory = directory.CreateDirectory("Second"); + + PhysicalFileSystemIdentity result = PhysicalDirectoryPath.GetIdentity(firstDirectory); + + PhysicalFileSystemIdentity secondIdentity = PhysicalDirectoryPath.GetIdentity(secondDirectory); + result.Should().NotBe(secondIdentity); + result.VolumeSerialNumber.Should().Be(secondIdentity.VolumeSerialNumber); + } + + [Fact] + public void GetIdentity_MissingDirectory_Throws() + { + using TestDirectory directory = new(); + string missingPath = directory.GetPath("Missing"); + + Action act = () => PhysicalDirectoryPath.GetIdentity(missingPath); + + act.Should().Throw(); + } + + [Fact] + public void GetFileIdentity_JunctionPath_ReturnsTheTargetFileIdentity() + { + using TestDirectory directory = new(); + string junctionPath = directory.GetPath("Link"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + junctionPath); + + PhysicalFileSystemIdentity result = PhysicalDirectoryPath.GetFileIdentity( + Path.Combine(junctionPath, "target.txt")); + + result.Should().Be(PhysicalDirectoryPath.GetFileIdentity(junction.CanaryFilePath)); + } + + /// + /// Deployed files are identified while the game or another writer still holds them open, so reading an identity + /// must never contend for the file. + /// + [Fact] + public void GetFileIdentity_FileHeldOpen_ReturnsTheSameIdentity() + { + using TestDirectory directory = new(); + string filePath = directory.CreateFile("state.yaml", "state"); + PhysicalFileSystemIdentity closedIdentity = PhysicalDirectoryPath.GetFileIdentity(filePath); + using FileStream heldOpen = new(filePath, FileMode.Open, FileAccess.Write, FileShare.None); + + PhysicalFileSystemIdentity result = PhysicalDirectoryPath.GetFileIdentity(filePath); + + result.Should().Be(closedIdentity); + } + + [Fact] + public void GetFileIdentity_DifferentFiles_ReturnsDifferentIdentities() + { + using TestDirectory directory = new(); + string firstPath = directory.CreateFile("first.txt", "first"); + string secondPath = directory.CreateFile("second.txt", "second"); + + PhysicalFileSystemIdentity result = PhysicalDirectoryPath.GetFileIdentity(firstPath); + + result.Should().NotBe(PhysicalDirectoryPath.GetFileIdentity(secondPath)); + } + + [Fact] + public void GetFileIdentity_MissingFile_Throws() + { + using TestDirectory directory = new(); + string missingPath = directory.GetPath("missing.txt"); + + Action act = () => PhysicalDirectoryPath.GetFileIdentity(missingPath); + + act.Should().Throw(); + } + + [Fact] + public void GetFileIdentity_DirectoryPath_Throws() + { + using TestDirectory directory = new(); + string directoryPath = directory.CreateDirectory("Content"); + + Action act = () => PhysicalDirectoryPath.GetFileIdentity(directoryPath); + + act.Should().Throw(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Integrity/Services/FileSystemContentIntegrityServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Integrity/Services/FileSystemContentIntegrityServiceTests.cs new file mode 100644 index 00000000..acc3c9ef --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Integrity/Services/FileSystemContentIntegrityServiceTests.cs @@ -0,0 +1,1036 @@ +using System; +using System.Collections.Generic; +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 GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Integrity.Services; +using GenLauncherGO.Infrastructure.Integrity.Support; +using GenLauncherGO.Infrastructure.Persistence.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Integrity.Services; + +public sealed class FileSystemContentIntegrityServiceTests +{ + [Fact] + public async Task VerifyAsync_ReportsVerificationErrorWhenSnapshotCannotBeReadAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + Directory.CreateDirectory(paths.IntegrityDirectory); + await File.WriteAllTextAsync(GetSnapshotPath(paths.IntegrityDirectory, "target"), "{", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.VerificationError && + issue.Action == IntegrityIssueAction.Block && + issue.RelativePath == "." && + !string.IsNullOrWhiteSpace(issue.Message)); + } + + /// + /// A snapshot the launcher cannot prove it owns is as untrustworthy as an unreadable one: a future schema may + /// have changed what the fields mean, and another target's document describes different content. + /// + [Theory] + [InlineData(2, "target")] + [InlineData(1, "other-target")] + public async Task VerifyAsync_SnapshotWithUnsupportedSchemaOrOwner_ReportsVerificationErrorAsync( + int schemaVersion, + string snapshotTargetId) + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + Directory.CreateDirectory(paths.IntegrityDirectory); + ContentIntegritySnapshotDocument snapshot = new( + schemaVersion, + snapshotTargetId, + ContentSourceKind.ManagedS3, + [], + []); + await File.WriteAllTextAsync(GetSnapshotPath(paths.IntegrityDirectory, "target"), JsonSerializer.Serialize(snapshot), TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.VerificationError && + issue.Action == IntegrityIssueAction.Block && + issue.RelativePath == "."); + } + + [Fact] + public async Task VerifyAsync_DetectsSameSizeSha256ModificationAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + string filePath = Path.Combine(content, "file.bin"); + await File.WriteAllTextAsync(filePath, "aaaa", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + await service.CaptureSnapshotAsync(paths, target, CancellationToken.None); + await File.WriteAllTextAsync(filePath, "bbbb", TestContext.Current.CancellationToken); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.ModifiedFile && + issue.Action == IntegrityIssueAction.Repair && + issue.RelativePath == "file.bin" && + issue.ExpectedSizeBytes == 4); + } + + [Fact] + public async Task VerifyAsync_CollectsMissingUnexpectedAndEmptyDirectoryIssuesAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + string expectedPath = Path.Combine(content, "expected.txt"); + await File.WriteAllTextAsync(expectedPath, "expected", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + await service.CaptureSnapshotAsync(paths, target, CancellationToken.None); + File.Delete(expectedPath); + await File.WriteAllTextAsync(Path.Combine(content, "unexpected.txt"), "unexpected", TestContext.Current.CancellationToken); + Directory.CreateDirectory(Path.Combine(content, "nested", "empty")); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + 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 VerifyAsyncAlways_ReportsManagedEmptyDirectoriesAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = Path.Combine(directory.Path, "content"); + Directory.CreateDirectory(Path.Combine(content, "nested", "empty")); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + await service.CaptureSnapshotAsync(paths, target, CancellationToken.None); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.EmptyDirectory && + issue.Action == IntegrityIssueAction.Delete && + issue.RelativePath == "nested/empty"); + } + + [Fact] + public async Task VerifyAsync_ClassifiesManagedSingleFileDifferencesAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + string expectedPath = Path.Combine(content, "expected.txt"); + await File.WriteAllTextAsync(expectedPath, "expected", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedSingleFile); + await service.CaptureSnapshotAsync(paths, target, CancellationToken.None); + File.Delete(expectedPath); + await File.WriteAllTextAsync(Path.Combine(content, "unexpected.txt"), "unexpected", TestContext.Current.CancellationToken); + Directory.CreateDirectory(Path.Combine(content, "empty")); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + 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 VerifyAsync_ClassifiesUnknownLegacyDifferencesForManualTrustAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + string filePath = Path.Combine(content, "file.txt"); + await File.WriteAllTextAsync(filePath, "before", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.UnknownLegacy); + await service.CaptureSnapshotAsync(paths, target, CancellationToken.None); + await File.WriteAllTextAsync(filePath, "after", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(content, "added.txt"), "added", TestContext.Current.CancellationToken); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.Issues.Should().NotBeEmpty(); + report.Issues.Should().OnlyContain(issue => issue.Action == IntegrityIssueAction.TrustAsManual); + } + + [Fact] + public async Task VerifyAsync_MarksManualDifferencesForAbsorptionAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + await File.WriteAllTextAsync(Path.Combine(content, "file.txt"), "before", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.Manual); + await service.CaptureSnapshotAsync(paths, target, CancellationToken.None); + await File.WriteAllTextAsync(Path.Combine(content, "file.txt"), "after", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(content, "added.txt"), "added", TestContext.Current.CancellationToken); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.Issues.Should().NotBeEmpty() + .And.OnlyContain(issue => issue.Action == IntegrityIssueAction.Absorb); + } + + /// + /// Manual content is where a user's own empty folders live, so a snapshot has to record them or every later + /// verification would ask to absorb the same folders again. + /// + [Fact] + public async Task VerifyAsync_ManualSnapshotWithEmptyDirectory_ReportsNoIssuesAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + await File.WriteAllTextAsync(Path.Combine(content, "file.txt"), "content", TestContext.Current.CancellationToken); + Directory.CreateDirectory(Path.Combine(content, "empty")); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.Manual); + await service.CaptureSnapshotAsync(paths, target, CancellationToken.None); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.HasIssues.Should().BeFalse(); + } + + [Fact] + public async Task CaptureSnapshotAsync_AbsorbsManualDifferencesAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + string filePath = Path.Combine(content, "file.txt"); + await File.WriteAllTextAsync(filePath, "before", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.Manual); + await service.CaptureSnapshotAsync(paths, target, CancellationToken.None); + await File.WriteAllTextAsync(filePath, "after", TestContext.Current.CancellationToken); + + await service.CaptureSnapshotAsync(paths, target, CancellationToken.None); + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.HasIssues.Should().BeFalse(); + } + + [Fact] + public async Task CaptureSnapshotAsync_CommitsCompleteDocumentThroughAtomicWriterAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + await File.WriteAllTextAsync(Path.Combine(content, "file.txt"), "content", TestContext.Current.CancellationToken); + RecordingAtomicFileWriter atomicFileWriter = new(); + FileSystemContentIntegrityService service = CreateService(atomicFileWriter); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + using var cancellationTokenSource = new CancellationTokenSource(); + + await service.CaptureSnapshotAsync(paths, target, cancellationTokenSource.Token); + + atomicFileWriter.WasWriteAsyncCalled.Should().BeTrue(); + atomicFileWriter.CancellationToken.Should().Be(cancellationTokenSource.Token); + atomicFileWriter.DestinationPath.Should().Be(GetSnapshotPath(paths.IntegrityDirectory, target.Id)); + ContentIntegritySnapshotDocument? snapshot = + JsonSerializer.Deserialize(atomicFileWriter.Contents!); + snapshot.Should().NotBeNull(); + snapshot!.TargetId.Should().Be(target.Id); + snapshot.Files.Should().ContainSingle().Which.RelativePath.Should().Be("file.txt"); + } + + /// + /// A later verification compares this document against a fresh scan, so the store orders its entries + /// itself rather than persisting whatever order the filesystem happened to enumerate. + /// + [Fact] + public async Task CaptureSnapshotAsync_OrdersEntriesIndependentlyOfEnumerationAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + await File.WriteAllTextAsync(Path.Combine(content, "zeta.txt"), "z", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(content, "alpha.txt"), "a", TestContext.Current.CancellationToken); + Directory.CreateDirectory(Path.Combine(content, "zulu")); + Directory.CreateDirectory(Path.Combine(content, "bravo")); + RecordingAtomicFileWriter atomicFileWriter = new(); + FileSystemContentIntegrityService service = CreateService(atomicFileWriter); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.Manual); + + await service.CaptureSnapshotAsync(paths, target, CancellationToken.None); + + ContentIntegritySnapshotDocument snapshot = + JsonSerializer.Deserialize(atomicFileWriter.Contents!)!; + snapshot.Files.Select(file => file.RelativePath).Should().Equal("alpha.txt", "zeta.txt"); + snapshot.EmptyDirectories.Should().Equal("bravo", "zulu"); + } + + [Fact] + public async Task IntegritySnapshots_GameNamespaceChange_SwitchWithoutServiceRebuildAsync() + { + using TestDirectory directory = new(); + string executableDirectory = directory.CreateDirectory("Launcher"); + var storagePaths = new LauncherStoragePaths(executableDirectory); + LauncherPaths generalsPaths = storagePaths.CreateGamePaths( + SupportedGame.Generals, + directory.CreateDirectory("GeneralsGame")); + LauncherPaths zeroHourPaths = storagePaths.CreateGamePaths( + SupportedGame.ZeroHour, + directory.CreateDirectory("ZeroHourGame")); + var service = new FileSystemContentIntegrityService( + new AtomicFileWriter(), + NullLogger.Instance); + string content = directory.CreateDirectory("content"); + await File.WriteAllTextAsync(Path.Combine(content, "file.txt"), "content", TestContext.Current.CancellationToken); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + + await service.CaptureSnapshotAsync(generalsPaths, target, CancellationToken.None); + ContentIntegrityReport zeroHourReport = await service.VerifyAsync( + zeroHourPaths, + new[] { target }, + CancellationToken.None); + + zeroHourReport.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.Untracked); + File.Exists(GetSnapshotPath(generalsPaths.IntegrityDirectory, target.Id)).Should().BeTrue(); + File.Exists(GetSnapshotPath(zeroHourPaths.IntegrityDirectory, target.Id)).Should().BeFalse(); + } + + [Fact] + public async Task VerifyAsync_PreservesIgnoredInactiveCacheFileAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + await File.WriteAllTextAsync(Path.Combine(content, "active.png"), "active", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(content, "inactive.png"), "inactive", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = new( + "target", + "Target", + content, + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.OrdinalIgnoreCase) { "inactive.png" }); + await service.CaptureSnapshotAsync(paths, target, CancellationToken.None); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.HasIssues.Should().BeFalse(); + } + + [Fact] + public async Task CaptureSnapshotIf_MatchesExpectedFileSetAsyncCapturesExistingManagedCacheWithoutMutationAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + string activePath = Path.Combine(content, "active.png"); + string inactivePath = Path.Combine(content, "inactive.png"); + await File.WriteAllTextAsync(activePath, "active", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(inactivePath, "inactive", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = new( + "target", + "Target", + content, + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.OrdinalIgnoreCase) { "inactive.png" }); + + bool captured = await service.CaptureSnapshotIfMatchesExpectedFileSetAsync( + paths, + target, + new HashSet(StringComparer.OrdinalIgnoreCase) { "active.png" }, + CancellationToken.None); + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + captured.Should().BeTrue(); + report.HasIssues.Should().BeFalse(); + (await File.ReadAllTextAsync(activePath, TestContext.Current.CancellationToken)).Should().Be("active"); + (await File.ReadAllTextAsync(inactivePath, TestContext.Current.CancellationToken)).Should().Be("inactive"); + } + + [Fact] + public async Task CaptureSnapshotIf_MatchesExpectedFileSetAsyncRejectsExtrasWithoutSnapshottingAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + await File.WriteAllTextAsync(Path.Combine(content, "active.png"), "active", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(content, "extra.png"), "extra", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + + bool captured = await service.CaptureSnapshotIfMatchesExpectedFileSetAsync( + paths, + target, + new HashSet(StringComparer.OrdinalIgnoreCase) { "active.png" }, + CancellationToken.None); + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + captured.Should().BeFalse(); + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.Untracked && + issue.Action == IntegrityIssueAction.Repair); + } + + /// + /// The expected file set describes files only, so a directory entry the manifest never mentioned still means the + /// content is not the package the launcher would be vouching for. + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task CaptureSnapshotIf_UnexpectedDirectoryEntry_LeavesTargetUntrackedAsync(bool linkedEntry) + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + await File.WriteAllTextAsync(Path.Combine(content, "active.png"), "active", TestContext.Current.CancellationToken); + string unexpectedEntryPath = Path.Combine(content, "unexpected"); + if (linkedEntry) + { + ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, unexpectedEntryPath); + } + else + { + Directory.CreateDirectory(unexpectedEntryPath); + } + + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + + bool captured = await service.CaptureSnapshotIfMatchesExpectedFileSetAsync( + paths, + target, + new HashSet(StringComparer.OrdinalIgnoreCase) { "active.png" }, + CancellationToken.None); + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + captured.Should().BeFalse(); + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.Untracked && + issue.Action == IntegrityIssueAction.Repair); + } + + [Fact] + public async Task VerifyAsync_ReportsIgnoredUnsafeLinkWithoutFollowingItAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + Path.Combine(content, "inactive.png")); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = new( + "target", + "Target", + content, + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.OrdinalIgnoreCase) { "inactive.png" }); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.UnsafeLink && + issue.Action == IntegrityIssueAction.Delete && + issue.RelativePath == "inactive.png"); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Theory] + [InlineData(ContentSourceKind.ManagedS3, IntegrityIssueAction.Repair)] + [InlineData(ContentSourceKind.ManagedSingleFile, IntegrityIssueAction.Redownload)] + [InlineData(ContentSourceKind.Manual, IntegrityIssueAction.Absorb)] + [InlineData(ContentSourceKind.UnknownLegacy, IntegrityIssueAction.TrustAsManual)] + public async Task VerifyAsync_ClassifiesUntrackedContentBySourceAsync( + ContentSourceKind sourceKind, + IntegrityIssueAction expectedAction) + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, sourceKind); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.Untracked && + issue.Action == expectedAction); + } + + [Fact] + public async Task VerifyAsync_RequiresMigrationWhenSourceClassificationChangesAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + await File.WriteAllTextAsync(Path.Combine(content, "file.txt"), "content", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget managedTarget = CreateTarget(content, ContentSourceKind.ManagedS3); + await service.CaptureSnapshotAsync(paths, managedTarget, CancellationToken.None); + ContentIntegrityTarget manualTarget = CreateTarget(content, ContentSourceKind.Manual); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { manualTarget }, + CancellationToken.None); + + report.Issues.Should().ContainSingle(issue => + issue.Kind == IntegrityIssueKind.Untracked && + issue.Action == IntegrityIssueAction.Absorb); + } + + [Fact] + public async Task ApplyCleanupAsync_DeletesConfirmedManagedExtrasAndEmptyDirectoriesAsync() + { + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + string nested = Path.Combine(content, "nested"); + string deeper = Path.Combine(nested, "deeper"); + Directory.CreateDirectory(deeper); + await File.WriteAllTextAsync(Path.Combine(deeper, "unexpected.txt"), "unexpected", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + CreateDeleteIssue(target, "nested/deeper/unexpected.txt") + }); + + await service.ApplyCleanupAsync(report, new[] { target }, CancellationToken.None); + + File.Exists(Path.Combine(deeper, "unexpected.txt")).Should().BeFalse(); + Directory.Exists(deeper).Should().BeFalse(); + Directory.Exists(nested).Should().BeFalse(); + } + + [Fact] + public async Task ApplyCleanupAsync_DeletesConfirmedDirectoryIssueAsync() + { + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "content"); + string unexpected = Path.Combine(content, "unexpected"); + Directory.CreateDirectory(unexpected); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + CreateDeleteIssue(target, "unexpected", IntegrityIssueKind.EmptyDirectory) + }); + + await service.ApplyCleanupAsync(report, new[] { target }, CancellationToken.None); + + Directory.Exists(unexpected).Should().BeFalse(); + } + + [Fact] + public async Task ApplyCleanupAsync_RejectsUnknownTargetAsync() + { + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + "missing", + "Missing", + ContentSourceKind.ManagedS3, + IntegrityIssueKind.UnexpectedFile, + IntegrityIssueAction.Delete, + "unexpected.txt") + }); + + Func cleanup = () => service.ApplyCleanupAsync( + report, + Array.Empty(), + CancellationToken.None); + + await cleanup.Should().ThrowAsync() + .WithMessage("The cleanup report references an unknown integrity target."); + } + + [Fact] + public async Task ApplyCleanupAsync_IgnoresMissingDeletedEntriesAsync() + { + using TestDirectory directory = new(); + string content = directory.CreateDirectory("content"); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + new ContentIntegrityIssue( + target.Id, + target.DisplayName, + target.SourceKind, + IntegrityIssueKind.UnexpectedFile, + IntegrityIssueAction.Delete, + "missing.txt"), + CreateDeleteIssue(target, "missing/missing.txt") + }); + + await service.ApplyCleanupAsync(report, new[] { target }, CancellationToken.None); + + Directory.Exists(content).Should().BeTrue(); + } + + [Fact] + public async Task ApplyCleanupAsync_SkipsEmptyDirectorySweepWhenRootIsMissingAsync() + { + using TestDirectory directory = new(); + string content = Path.Combine(directory.Path, "missing-content"); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + CreateDeleteIssue(target, "missing.txt") + }); + + await service.ApplyCleanupAsync(report, new[] { target }, CancellationToken.None); + + Directory.Exists(content).Should().BeFalse(); + } + + [Fact] + public async Task ApplyCleanupAsync_PreservesIgnoredAndNonEmptyDirectoriesAsync() + { + 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", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(nonEmpty, "keep.txt"), "keep", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget( + content, + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.Ordinal) { @"\IGNORED/" }); + ContentIntegrityReport report = new(new[] + { + CreateDeleteIssue(target, "unexpected/unexpected.txt") + }); + + await service.ApplyCleanupAsync(report, new[] { target }, CancellationToken.None); + + File.Exists(unexpectedFile).Should().BeFalse(); + Directory.Exists(unexpected).Should().BeFalse(); + Directory.Exists(ignored).Should().BeTrue(); + Directory.Exists(nonEmpty).Should().BeTrue(); + } + + [Fact] + public async Task VerifyAsync_RejectsManualLinkWithoutFollowingItAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + Path.Combine(content, "linked.txt")); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.Manual); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + 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(paths, target, CancellationToken.None); + await capture.Should().ThrowAsync(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public async Task VerifyAsync_ReportsLinkedTargetRootWithoutFollowingItAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.GetPath("content"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, content); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.UnsafeLink && + issue.Action == IntegrityIssueAction.Delete && + issue.RelativePath == "."); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public async Task ApplyCleanupAsync_DeletesLinkedTargetRootWithoutDeletingTargetAsync() + { + using TestDirectory directory = new(); + string content = directory.GetPath("content"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, content); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + CreateDeleteIssue(target, ".", IntegrityIssueKind.UnsafeLink) + }); + + await service.ApplyCleanupAsync(report, new[] { target }, CancellationToken.None); + + Directory.Exists(content).Should().BeFalse(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + /// + /// A linked target root is the one arrangement where every issue path already crosses the link, so the cleanup + /// has to refuse the whole report instead of sweeping directories inside somebody else's folder. + /// + [Fact] + public async Task ApplyCleanupAsync_LinkedTargetRoot_RejectsWithoutTouchingTargetAsync() + { + using TestDirectory directory = new(); + string content = directory.GetPath("content"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, content); + string targetEmptyDirectory = directory.CreateDirectory("ExternalTarget/empty"); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + CreateDeleteIssue(target, "target.txt") + }); + + Func cleanup = () => service.ApplyCleanupAsync( + report, + new[] { target }, + CancellationToken.None); + + await cleanup.Should().ThrowAsync(); + Directory.Exists(content).Should().BeTrue(); + Directory.Exists(targetEmptyDirectory).Should().BeTrue(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public async Task ApplyCleanupAsync_RejectsLinkedAncestorWithoutDeletingTargetAsync() + { + using TestDirectory directory = new(); + string content = directory.CreateDirectory("content"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + Path.Combine(content, "linked")); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + CreateDeleteIssue(target, "linked/target.txt") + }); + + Func cleanup = () => service.ApplyCleanupAsync( + report, + new[] { target }, + CancellationToken.None); + + await cleanup.Should().ThrowAsync(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public async Task ApplyCleanupAsync_RejectsPathTraversalAsync() + { + using TestDirectory directory = new(); + string content = directory.CreateDirectory("content"); + string outsideFile = directory.CreateFile("outside.txt", "outside"); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] + { + CreateDeleteIssue(target, "../outside.txt") + }); + + Func cleanup = () => service.ApplyCleanupAsync( + report, + new[] { target }, + CancellationToken.None); + + await cleanup.Should().ThrowAsync(); + (await File.ReadAllTextAsync(outsideFile, TestContext.Current.CancellationToken)).Should().Be("outside"); + } + + /// + /// A snapshot proves what the content was when it was captured, not that nothing has been linked into it + /// since, so tracked content is checked for unsafe links exactly like untracked content is. + /// + [Fact] + public async Task VerifyAsync_LinkAddedAfterSnapshot_ReportsUnsafeLinkAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + await File.WriteAllTextAsync(Path.Combine(content, "file.txt"), "content", TestContext.Current.CancellationToken); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + await service.CaptureSnapshotAsync(paths, target, CancellationToken.None); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + Path.Combine(content, "linked")); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.UnsafeLink && + issue.Action == IntegrityIssueAction.Delete && + issue.RelativePath == "linked"); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + /// + /// A file the launcher cannot read is content it cannot vouch for, so verification blocks on it by name + /// instead of treating the unreadable entry as if it were simply missing. + /// + [Fact] + public async Task VerifyAsync_UnreadableFile_ReportsVerificationErrorAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = CreatePaths(directory); + string content = directory.CreateDirectory("content"); + string lockedPath = directory.CreateFile("content/locked.bin", "locked"); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + await service.CaptureSnapshotAsync(paths, target, CancellationToken.None); + using FileStream exclusiveHandle = new(lockedPath, FileMode.Open, FileAccess.Read, FileShare.None); + + ContentIntegrityReport report = await service.VerifyAsync( + paths, + new[] { target }, + CancellationToken.None); + + report.Issues.Should().Contain(issue => + issue.Kind == IntegrityIssueKind.VerificationError && + issue.Action == IntegrityIssueAction.Block && + issue.RelativePath == "locked.bin"); + } + + [Fact] + public async Task ApplyCleanupAsync_CancelledToken_KeepsConfirmedEntryAsync() + { + using TestDirectory directory = new(); + string content = directory.CreateDirectory("content"); + string unexpectedPath = directory.CreateFile("content/unexpected.txt", "unexpected"); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget target = CreateTarget(content, ContentSourceKind.ManagedS3); + ContentIntegrityReport report = new(new[] { CreateDeleteIssue(target, "unexpected.txt") }); + using CancellationTokenSource cancellation = new(); + await cancellation.CancelAsync(); + + Func cleanup = () => service.ApplyCleanupAsync( + report, + new[] { target }, + cancellation.Token); + + await cleanup.Should().ThrowAsync(); + File.Exists(unexpectedPath).Should().BeTrue(); + } + + /// + /// The empty-directory sweep is the one part of cleanup that deletes entries no issue named, so it runs only + /// inside the targets the user actually confirmed deletions for. + /// + [Fact] + public async Task ApplyCleanupAsync_TargetWithoutDeleteIssues_KeepsEmptyDirectoriesAsync() + { + using TestDirectory directory = new(); + string cleanedContent = directory.CreateDirectory("cleaned"); + string untouchedContent = directory.CreateDirectory("untouched"); + directory.CreateFile("cleaned/nested/unexpected.txt", "unexpected"); + string untouchedEmptyDirectory = directory.CreateDirectory("untouched/empty"); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget cleanedTarget = CreateTarget("cleaned", cleanedContent); + ContentIntegrityTarget untouchedTarget = CreateTarget("untouched", untouchedContent); + ContentIntegrityReport report = new(new[] + { + CreateDeleteIssue(cleanedTarget, "nested/unexpected.txt") + }); + + await service.ApplyCleanupAsync( + report, + new[] { cleanedTarget, untouchedTarget }, + CancellationToken.None); + + Directory.Exists(Path.Combine(cleanedContent, "nested")).Should().BeFalse(); + Directory.Exists(untouchedEmptyDirectory).Should().BeTrue(); + } + + [Fact] + public async Task ApplyCleanupAsync_MultipleTargets_SweepsEveryTargetWithDeletionsAsync() + { + using TestDirectory directory = new(); + string firstContent = directory.CreateDirectory("first"); + string secondContent = directory.CreateDirectory("second"); + directory.CreateFile("first/nested/unexpected.txt", "unexpected"); + directory.CreateFile("second/nested/unexpected.txt", "unexpected"); + FileSystemContentIntegrityService service = CreateService(); + ContentIntegrityTarget firstTarget = CreateTarget("first", firstContent); + ContentIntegrityTarget secondTarget = CreateTarget("second", secondContent); + ContentIntegrityReport report = new(new[] + { + CreateDeleteIssue(firstTarget, "nested/unexpected.txt"), + CreateDeleteIssue(secondTarget, "nested/unexpected.txt") + }); + + await service.ApplyCleanupAsync( + report, + new[] { firstTarget, secondTarget }, + CancellationToken.None); + + Directory.Exists(Path.Combine(firstContent, "nested")).Should().BeFalse(); + Directory.Exists(Path.Combine(secondContent, "nested")).Should().BeFalse(); + } + + private static LauncherPaths CreatePaths(TestDirectory directory) + { + return TestLauncherPaths.Create(Path.Combine(directory.Path, "Game")); + } + + private static FileSystemContentIntegrityService CreateService( + IAtomicFileWriter? atomicFileWriter = null) + { + return new FileSystemContentIntegrityService( + atomicFileWriter ?? new AtomicFileWriter(), + 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 ContentIntegrityTarget CreateTarget(string id, string root) + { + return new ContentIntegrityTarget( + id, + id, + root, + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.OrdinalIgnoreCase)); + } + + private static ContentIntegrityIssue CreateDeleteIssue( + ContentIntegrityTarget target, + string relativePath, + IntegrityIssueKind kind = IntegrityIssueKind.UnexpectedFile) + { + return new ContentIntegrityIssue( + target.Id, + target.DisplayName, + target.SourceKind, + kind, + IntegrityIssueAction.Delete, + relativePath); + } + + 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/Integrity/Support/ContentIntegrityPathTests.cs b/GenLauncherGO.Tests/Infrastructure/Integrity/Support/ContentIntegrityPathTests.cs new file mode 100644 index 00000000..fb53a9f2 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Integrity/Support/ContentIntegrityPathTests.cs @@ -0,0 +1,24 @@ +using System; +using System.IO; +using GenLauncherGO.Infrastructure.Integrity.Support; + +namespace GenLauncherGO.Tests.Infrastructure.Integrity.Support; + +public sealed class ContentIntegrityPathTests +{ + /// + /// Every relative path a scan produces is later resolved back against the target root and can be handed to a + /// delete, so one that already points outside the target must stop here rather than become a usable path. + /// + [Fact] + public void GetRelativePath_PathOutsideRoot_Throws() + { + using TestDirectory directory = new(); + string root = directory.CreateDirectory("content"); + string outsidePath = directory.CreateFile("outside.txt", "outside"); + + Action getRelativePath = () => ContentIntegrityPath.GetRelativePath(root, outsidePath); + + getRelativePath.Should().Throw(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Integrity/Support/ContentIntegrityScannerTests.cs b/GenLauncherGO.Tests/Infrastructure/Integrity/Support/ContentIntegrityScannerTests.cs new file mode 100644 index 00000000..8469634a --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Integrity/Support/ContentIntegrityScannerTests.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Security.AccessControl; +using System.Security.Principal; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Infrastructure.Integrity.Support; + +namespace GenLauncherGO.Tests.Infrastructure.Integrity.Support; + +public sealed class ContentIntegrityScannerTests +{ + /// + /// Content the user has not installed yet is absent, not broken, so the scan describes an empty target instead + /// of failing on the missing directory. + /// + [Fact] + public async Task ScanAsync_MissingRootDirectory_ReturnsEmptyResultAsync() + { + using TestDirectory directory = new(); + ContentIntegrityTarget target = CreateTarget(directory.GetPath("missing")); + + ContentIntegrityScanResult scan = await ContentIntegrityScanner.ScanAsync(target, CancellationToken.None); + + scan.Files.Should().BeEmpty(); + scan.EmptyDirectories.Should().BeEmpty(); + scan.UnsafeLinks.Should().BeEmpty(); + scan.Errors.Should().BeEmpty(); + } + + [Fact] + public async Task ScanAsync_CancelledToken_ThrowsAsync() + { + using TestDirectory directory = new(); + string content = directory.CreateDirectory("content"); + directory.CreateFile("content/file.txt", "content"); + ContentIntegrityTarget target = CreateTarget(content); + using CancellationTokenSource cancellation = new(); + await cancellation.CancelAsync(); + + Func scan = () => ContentIntegrityScanner.ScanAsync(target, cancellation.Token); + + await scan.Should().ThrowAsync(); + } + + /// + /// Only a directory that actually holds nothing is an unexpected empty directory. A parent that merely contains + /// one would otherwise be reported alongside every leaf below it. + /// + [Fact] + public async Task ScanAsync_NestedDirectoryWithEntries_ReportsOnlyTheEmptyLeafAsync() + { + using TestDirectory directory = new(); + string content = directory.CreateDirectory("content"); + directory.CreateFile("content/nested/file.txt", "file"); + directory.CreateDirectory("content/nested/empty"); + ContentIntegrityTarget target = CreateTarget(content); + + ContentIntegrityScanResult scan = await ContentIntegrityScanner.ScanAsync(target, CancellationToken.None); + + scan.EmptyDirectories.Should().ContainSingle().Which.Should().Be("nested/empty"); + } + + /// + /// A link is one unsafe entry: reporting it twice would hand the cleanup a delete it cannot repeat, and + /// descending into it would read and trust whatever the link points at. + /// + [Fact] + public async Task ScanAsync_LinkedDirectory_ReportsUnsafeLinkOnceAsync() + { + using TestDirectory directory = new(); + string content = directory.CreateDirectory("content"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + Path.Combine(content, "linked")); + ContentIntegrityTarget target = CreateTarget(content); + + ContentIntegrityScanResult scan = await ContentIntegrityScanner.ScanAsync(target, CancellationToken.None); + + scan.UnsafeLinks.Should().ContainSingle().Which.Should().Be("linked"); + scan.Files.Should().BeEmpty(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + /// + /// A file the scan cannot read is not a file it can vouch for, so it becomes a reported error rather than an + /// entry that silently disappears from the scanned set. + /// + [Fact] + public async Task ScanAsync_UnreadableFile_ReportsScanErrorAsync() + { + using TestDirectory directory = new(); + string content = directory.CreateDirectory("content"); + string lockedPath = directory.CreateFile("content/locked.bin", "locked"); + ContentIntegrityTarget target = CreateTarget(content); + using FileStream exclusiveHandle = new(lockedPath, FileMode.Open, FileAccess.Read, FileShare.None); + + ContentIntegrityScanResult scan = await ContentIntegrityScanner.ScanAsync(target, CancellationToken.None); + + scan.Errors.Should().ContainSingle().Which.RelativePath.Should().Be("locked.bin"); + scan.Files.Should().BeEmpty(); + } + + /// + /// A directory the launcher cannot list is content it cannot account for. Reporting the directory as an error + /// is what keeps it from looking like an empty folder whose files have all gone missing. + /// + [Fact] + public async Task ScanAsync_UnreadableDirectory_ReportsScanErrorAsync() + { + using TestDirectory directory = new(); + string content = directory.CreateDirectory("content"); + string unreadablePath = directory.CreateDirectory("content/unreadable"); + ContentIntegrityTarget target = CreateTarget(content); + using DeniedDirectoryListing deniedListing = new(unreadablePath); + + ContentIntegrityScanResult scan = await ContentIntegrityScanner.ScanAsync(target, CancellationToken.None); + + scan.Errors.Should().ContainSingle().Which.RelativePath.Should().Be("unreadable"); + scan.EmptyDirectories.Should().BeEmpty(); + } + + private static ContentIntegrityTarget CreateTarget(string root) + { + return new ContentIntegrityTarget( + "target", + "Target", + root, + ContentSourceKind.ManagedS3, + new HashSet(StringComparer.OrdinalIgnoreCase)); + } +} + +/// +/// Denies the current account permission to list one directory for the duration of a test, which is the only way +/// to make a directory enumeration fail on demand without elevation. The deny entry is removed on disposal so the +/// owning temporary directory can still be enumerated and deleted. +/// +internal sealed class DeniedDirectoryListing : IDisposable +{ + private readonly FileSystemAccessRule _denyRule; + + private readonly DirectoryInfo _directory; + + public DeniedDirectoryListing(string path) + { + using var identity = WindowsIdentity.GetCurrent(); + SecurityIdentifier user = identity.User + ?? throw new InvalidOperationException("The current account has no user SID."); + + _directory = new DirectoryInfo(path); + _denyRule = new FileSystemAccessRule(user, FileSystemRights.ListDirectory, AccessControlType.Deny); + ChangeAccess(security => security.AddAccessRule(_denyRule)); + } + + public void Dispose() + { + ChangeAccess(security => security.RemoveAccessRule(_denyRule)); + } + + private void ChangeAccess(Action change) + { + DirectorySecurity security = _directory.GetAccessControl(); + change(security); + _directory.SetAccessControl(security); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/DeploymentLaunchPreparationServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/DeploymentLaunchPreparationServiceTests.cs new file mode 100644 index 00000000..c5d9b1d1 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/DeploymentLaunchPreparationServiceTests.cs @@ -0,0 +1,201 @@ +using System.IO; +using System.Linq; +using System.Threading; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Mods.Services; +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 DeploymentLaunchPreparationServiceTests +{ + [Fact] + public void Prepare_ResolvesSelectedVersionPathsAndUsesSelectionOrderForPrecedence() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + LauncherContentVersion[] versions = + [ + TestLauncherContent.Version("Rise", "1.0"), + TestLauncherContent.Version("Balance", "2.0", ModificationType.Patch, "Rise"), + TestLauncherContent.Version("Maps", "3.0", ModificationType.Addon, "Rise") + ]; + WriteVersionFile(paths, versions[0], "Data/file.ini", "mod"); + WriteVersionFile(paths, versions[1], "Data/file.ini", "patch"); + WriteVersionFile(paths, versions[2], "Data/file.ini", "addon"); + DeploymentLaunchPreparationService service = CreateService(); + + bool succeeded = service.Prepare( + new LaunchPreparationRequest( + paths, + versions, + false), + CancellationToken.None); + + succeeded.Should().BeTrue(); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().Be("addon"); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Prepare_RespectsBaseGameScriptSettingAndCleanupRestoresFiles(bool disableBaseGameScripts) + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + LauncherContentVersion version = TestLauncherContent.Version("Rise", "1.0"); + Directory.CreateDirectory(GetVersionRoot(paths, version)); + string scriptsDirectory = Path.Combine(paths.GameDirectory, "Data", "Scripts"); + Directory.CreateDirectory(scriptsDirectory); + string[] scriptPaths = + [ + Path.Combine(scriptsDirectory, "MultiplayerScripts.scb"), + Path.Combine(scriptsDirectory, "SkirmishScripts.scb"), + Path.Combine(scriptsDirectory, "Scripts.ini") + ]; + foreach (string scriptPath in scriptPaths) + { + File.WriteAllText(scriptPath, Path.GetFileName(scriptPath)); + } + + DeploymentLaunchPreparationService service = CreateService(); + + bool prepareSucceeded = service.Prepare( + new LaunchPreparationRequest( + paths, + new[] { version }, + disableBaseGameScripts), + CancellationToken.None); + + prepareSucceeded.Should().BeTrue(); + foreach (string scriptPath in scriptPaths) + { + File.Exists(scriptPath).Should().Be(!disableBaseGameScripts); + } + + bool cleanupSucceeded = service.Cleanup(paths, CancellationToken.None); + + cleanupSucceeded.Should().BeTrue(); + foreach (string scriptPath in scriptPaths) + { + File.ReadAllText(scriptPath).Should().Be(Path.GetFileName(scriptPath)); + } + } + + [Fact] + public void Prepare_ReportsDeploymentFailure() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + DeploymentLaunchPreparationService service = CreateService(); + + bool succeeded = service.Prepare( + new LaunchPreparationRequest( + paths, + new[] { TestLauncherContent.Version("Missing", "1.0") }, + false), + CancellationToken.None); + + succeeded.Should().BeFalse(); + } + + [Fact] + public void Cleanup_WhenBackupIsCorrupt_ReturnsFalse() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + LauncherContentVersion version = TestLauncherContent.Version("Rise", "1.0"); + WriteVersionFile(paths, version, "Data/file.ini", "mod"); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, "original"); + DeploymentLaunchPreparationService service = CreateService(); + service.Prepare( + new LaunchPreparationRequest(paths, new[] { version }, false), + CancellationToken.None).Should().BeTrue(); + string backupPath = Directory + .EnumerateFiles( + Path.Combine(paths.DeploymentDirectory, DeploymentStateStore.BackupsDirectoryName), + "*", + SearchOption.AllDirectories) + .Single(); + File.WriteAllText(backupPath, "corrupt"); + + bool succeeded = service.Cleanup(paths, CancellationToken.None); + + succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("mod"); + File.ReadAllText(backupPath).Should().Be("corrupt"); + } + + [Fact] + public void Recover_WhenManifestAndJournalAreUnreadable_ReturnsFalse() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, "user data"); + File.WriteAllText(Path.Combine(paths.DeploymentDirectory, "active.json"), "{not-json"); + File.WriteAllText(Path.Combine(paths.DeploymentDirectory, "journal.jsonl"), "{also-not-json"); + DeploymentLaunchPreparationService service = CreateService(); + + bool succeeded = service.Recover(paths, CancellationToken.None); + + succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("user data"); + } + + [Fact] + public void Recover_RestoresJournaledBackupThroughLaunchPreparationBoundary() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string deploymentRoot = paths.DeploymentDirectory; + string backupPath = Path.Combine(deploymentRoot, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + File.WriteAllText(backupPath, "original"); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileBackedUp( + "Data/file.ini", + "Backups/crash/Data/file.ini", + DeploymentJournalWriter.FingerprintFrom("original"), + "Backups/crash/Data/file.ini.partial")); + DeploymentLaunchPreparationService service = CreateService(); + + bool succeeded = service.Recover(paths, CancellationToken.None); + + succeeded.Should().BeTrue(); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().Be("original"); + File.Exists(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + private static DeploymentLaunchPreparationService CreateService() + { + var deploymentEngine = new FileSystemDeploymentService( + new FakeHardLinkCreator { CanCreateHardLinks = false }, + NullLogger.Instance); + return new DeploymentLaunchPreparationService(deploymentEngine); + } + + private static void WriteVersionFile( + LauncherPaths paths, + LauncherContentVersion version, + string relativePath, + string contents) + { + string filePath = Path.Combine(GetVersionRoot(paths, version), relativePath); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + File.WriteAllText(filePath, contents); + } + + private static string GetVersionRoot(LauncherPaths paths, LauncherContentVersion version) + { + return LauncherContentPathResolver.ResolveVersionPath(paths, version.ContentKey)!.FullPath; + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemDeploymentServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemDeploymentServiceTests.cs new file mode 100644 index 00000000..1c5a9ab0 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemDeploymentServiceTests.cs @@ -0,0 +1,1895 @@ +using System; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Launching.Services; +using GenLauncherGO.Infrastructure.Launching.Support; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class FileSystemDeploymentServiceTests +{ + [Fact] + public void Prepare_UsesHardLinkWhenCreatorSucceeds() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FakeHardLinkCreator hardLinks = new(); + FileSystemDeploymentService service = CreateService(hardLinks); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().Be("mod"); + hardLinks.CreatedLinks.Should().ContainSingle(); + hardLinks.CreatedLinks[0].TargetPath.Should().NotBe(Path.Combine(paths.GameDirectory, "Data", "file.ini")); + File.Exists(hardLinks.CreatedLinks[0].TargetPath).Should().BeFalse(); + } + + [Fact] + public void Prepare_CopiesFileWhenHardLinkCreatorFails() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + DateTime packageWriteTimeUtc = new(2022, 4, 5, 6, 7, 8, DateTimeKind.Utc); + File.SetLastWriteTimeUtc(Path.Combine(packageRoot, "Data", "file.ini"), packageWriteTimeUtc); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + File.ReadAllText(targetPath).Should().Be("mod"); + File.GetLastWriteTimeUtc(targetPath).Should().Be(packageWriteTimeUtc); + Directory.EnumerateFiles(paths.GameDirectory, "*.tmp", SearchOption.AllDirectories).Should().BeEmpty(); + } + + [Fact] + public void Prepare_WhenTheCopyStagingPathIsOccupied_FailsWithoutTouchingTheGameFile() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + string? deployStagingPath = null; + FakeHardLinkCreator hardLinks = new() + { + CanCreateHardLinks = false, + SameVolumeCheck = (_, secondPath) => + { + if (!secondPath.Contains(".GenLauncherGO-deploy-", StringComparison.Ordinal)) + { + return; + } + + deployStagingPath = secondPath; + Directory.CreateDirectory(Path.GetDirectoryName(secondPath)!); + File.WriteAllText(secondPath, "squatter"); + } + }; + FileSystemDeploymentService service = CreateService(hardLinks); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + deployStagingPath.Should().NotBeNull(); + File.Exists(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().BeFalse(); + Directory.EnumerateFiles(paths.GameDirectory, "*.tmp", SearchOption.AllDirectories).Should().BeEmpty(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeFalse(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public void Prepare_WhenTheStagedHardLinkIsNotTheSourceFile_FailsWithoutDeployingIt() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FakeHardLinkCreator hardLinks = new() + { + UseRealHardLinks = false, + CreateHook = (targetPath, _) => + { + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, "impostor"); + } + }; + FileSystemDeploymentService service = CreateService(hardLinks); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.Exists(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().BeFalse(); + Directory.EnumerateFiles(paths.GameDirectory, "*.tmp", SearchOption.AllDirectories).Should().BeEmpty(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeFalse(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public void Prepare_OverActiveDeployment_RestoresOriginalsBeforeDeployingNewPackages() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string firstPackageRoot = CreatePackage(paths, "First", ("Data/file.ini", "a")); + string secondPackageRoot = CreatePackage(paths, "Second", ("Data/file.ini", "b")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(firstPackageRoot, 0) }, + Array.Empty(), + CancellationToken.None).Succeeded.Should().BeTrue(); + + DeploymentResult secondPrepareResult = service.Prepare( + paths, + new[] { CreateDeploymentPackage(secondPackageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + secondPrepareResult.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("b"); + + DeploymentResult cleanupResult = service.Cleanup(paths, CancellationToken.None); + + cleanupResult.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + Directory.Exists(Path.Combine(paths.DeploymentDirectory, "Backups")).Should().BeFalse(); + } + + [Fact] + public void Cleanup_RestoresBackedUpOriginalFile() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + 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 { CanCreateHardLinks = false }); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + DeploymentResult result = service.Cleanup(paths, CancellationToken.None); + + 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 void PrepareAndCleanup_CrossVolumeFallbackRestoresOriginalContentAndMetadata() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + DateTime originalWriteTimeUtc = new(2012, 3, 4, 5, 6, 8, DateTimeKind.Utc); + File.SetLastWriteTimeUtc(targetPath, originalWriteTimeUtc); + File.SetAttributes(targetPath, FileAttributes.ReadOnly | FileAttributes.Hidden); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FakeHardLinkCreator hardLinks = new() { PathsOnSameVolume = false }; + FileSystemDeploymentService service = CreateService(hardLinks); + + try + { + DeploymentResult prepareResult = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + prepareResult.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("mod"); + hardLinks.CreatedLinks.Should().BeEmpty(); + + DeploymentResult cleanupResult = service.Cleanup(paths, CancellationToken.None); + + cleanupResult.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + File.GetLastWriteTimeUtc(targetPath).Should().Be(originalWriteTimeUtc); + File.GetAttributes(targetPath).Should().HaveFlag(FileAttributes.ReadOnly); + File.GetAttributes(targetPath).Should().HaveFlag(FileAttributes.Hidden); + Directory.Exists(Path.Combine(paths.DeploymentDirectory, "Backups")).Should().BeFalse(); + Directory.EnumerateFiles(paths.GameDirectory, "*.tmp", SearchOption.AllDirectories).Should().BeEmpty(); + } + finally + { + if (File.Exists(targetPath)) + { + File.SetAttributes(targetPath, FileAttributes.Normal); + } + } + } + + [Fact] + public void Cleanup_CrossVolumeRestoreCollisionHoldsTheBackupBytes_DeletesTheStagingFile() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + string? restoreStagingPath = null; + FakeHardLinkCreator hardLinks = new() + { + CanCreateHardLinks = false, + PathsOnSameVolume = false, + SameVolumeCheck = (_, secondPath) => + { + if (!secondPath.Contains(".GenLauncherGO-restore-", StringComparison.Ordinal)) + { + return; + } + + restoreStagingPath = secondPath; + File.WriteAllText(secondPath, "original"); + File.SetAttributes(secondPath, FileAttributes.ReadOnly); + } + }; + FileSystemDeploymentService service = CreateService(hardLinks); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None).Succeeded.Should().BeTrue(); + + DeploymentResult cleanupResult = service.Cleanup(paths, CancellationToken.None); + + cleanupResult.Succeeded.Should().BeFalse(); + restoreStagingPath.Should().NotBeNull(); + File.Exists(restoreStagingPath!).Should().BeFalse(); + File.Exists(targetPath).Should().BeFalse(); + Directory.EnumerateFiles( + Path.Combine(paths.DeploymentDirectory, "Backups"), + "*", + SearchOption.AllDirectories) + .Should().ContainSingle(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeTrue(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")).Should().BeTrue(); + } + + [Fact] + public void Cleanup_CrossVolumeRestoreCollisionHoldsForeignBytes_LeavesTheStagingFileUntouched() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + string? restoreStagingPath = null; + FakeHardLinkCreator hardLinks = new() + { + CanCreateHardLinks = false, + PathsOnSameVolume = false, + SameVolumeCheck = (_, secondPath) => + { + if (!secondPath.Contains(".GenLauncherGO-restore-", StringComparison.Ordinal)) + { + return; + } + + restoreStagingPath = secondPath; + File.WriteAllText(secondPath, "foreign"); + File.SetAttributes(secondPath, FileAttributes.ReadOnly); + } + }; + FileSystemDeploymentService service = CreateService(hardLinks); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None).Succeeded.Should().BeTrue(); + + try + { + DeploymentResult cleanupResult = service.Cleanup(paths, CancellationToken.None); + + cleanupResult.Succeeded.Should().BeFalse(); + restoreStagingPath.Should().NotBeNull(); + File.ReadAllText(restoreStagingPath!).Should().Be("foreign"); + File.Exists(targetPath).Should().BeFalse(); + Directory.EnumerateFiles( + Path.Combine(paths.DeploymentDirectory, "Backups"), + "*", + SearchOption.AllDirectories) + .Should().ContainSingle(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeTrue(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")).Should().BeTrue(); + } + finally + { + if (File.Exists(restoreStagingPath)) + { + File.SetAttributes(restoreStagingPath!, FileAttributes.Normal); + } + } + } + + [Fact] + public void Cleanup_CrossVolumeBackupChangesBeforeRestore_FailsClosedAndPreservesChangedBytes() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + string? restoreStagingPath = null; + FakeHardLinkCreator hardLinks = new() + { + CanCreateHardLinks = false, + PathsOnSameVolume = false, + SameVolumeCheck = (sourcePath, secondPath) => + { + if (!secondPath.Contains(".GenLauncherGO-restore-", StringComparison.Ordinal)) + { + return; + } + + restoreStagingPath = secondPath; + File.WriteAllText(sourcePath, "tampered"); + } + }; + FileSystemDeploymentService service = CreateService(hardLinks); + DeploymentResult prepareResult = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + prepareResult.Succeeded.Should().BeTrue(); + + DeploymentResult cleanupResult = service.Cleanup(paths, CancellationToken.None); + + cleanupResult.Succeeded.Should().BeFalse(); + restoreStagingPath.Should().NotBeNull(); + File.ReadAllText(restoreStagingPath!).Should().Be("tampered"); + File.Exists(targetPath).Should().BeFalse(); + Directory.EnumerateFiles( + Path.Combine(paths.DeploymentDirectory, "Backups"), + "*", + SearchOption.AllDirectories) + .Should().ContainSingle(file => File.ReadAllText(file) == "tampered"); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeTrue(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")).Should().BeTrue(); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Cleanup_BackupMissingOrCorrupt_FailsClosedAndPreservesRecoveryState(bool deleteBackup) + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + string backupPath = Directory.EnumerateFiles( + Path.Combine(paths.DeploymentDirectory, "Backups"), + "*", + SearchOption.AllDirectories) + .Single(); + if (deleteBackup) + { + File.Delete(backupPath); + } + else + { + File.WriteAllText(backupPath, "corrupt"); + } + + DeploymentResult result = service.Cleanup(paths, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("mod"); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeTrue(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")).Should().BeTrue(); + if (!deleteBackup) + { + File.ReadAllText(backupPath).Should().Be("corrupt"); + } + } + + [Fact] + public void Cleanup_RestoresEqualContentOriginalInsteadOfLeavingDeployedHardLink() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "same"); + DateTime originalWriteTimeUtc = new(2012, 3, 4, 5, 6, 8, DateTimeKind.Utc); + File.SetLastWriteTimeUtc(targetPath, originalWriteTimeUtc); + File.SetAttributes(targetPath, FileAttributes.ReadOnly | FileAttributes.Hidden); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "same")); + string packageSourcePath = Path.Combine(packageRoot, "Data", "file.ini"); + File.SetLastWriteTimeUtc( + packageSourcePath, + new DateTime(2022, 4, 5, 6, 7, 8, DateTimeKind.Utc)); + FileSystemDeploymentService service = CreateService(new WindowsHardLinkCreator()); + + try + { + DeploymentResult prepareResult = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + prepareResult.Succeeded.Should().BeTrue(); + DeploymentResult cleanupResult = service.Cleanup(paths, CancellationToken.None); + + cleanupResult.Succeeded.Should().BeTrue(); + File.GetLastWriteTimeUtc(targetPath).Should().Be(originalWriteTimeUtc); + File.GetAttributes(targetPath).Should().HaveFlag(FileAttributes.ReadOnly); + File.GetAttributes(targetPath).Should().HaveFlag(FileAttributes.Hidden); + File.SetAttributes(packageSourcePath, FileAttributes.Normal); + File.WriteAllText(packageSourcePath, "package changed"); + File.ReadAllText(targetPath).Should().Be("same"); + } + finally + { + if (File.Exists(targetPath)) + { + File.SetAttributes(targetPath, FileAttributes.Normal); + } + + if (File.Exists(packageSourcePath)) + { + File.SetAttributes(packageSourcePath, FileAttributes.Normal); + } + } + } + + [Fact] + public void Cleanup_LeavesReplacementOfDeployedHardLinkUntouchedEvenWhenBytesMatch() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + FileSystemDeploymentService service = CreateService(new WindowsHardLinkCreator()); + DeploymentResult prepareResult = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + prepareResult.Succeeded.Should().BeTrue(); + + File.Delete(targetPath); + File.WriteAllText(targetPath, "mod"); + + DeploymentResult cleanupResult = service.Cleanup(paths, CancellationToken.None); + + cleanupResult.Succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("mod"); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeTrue(); + } + + [Fact] + public void Prepare_CopiesReadOnlyPackageFileWithoutChangingPackageAttributes() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + string packageSourcePath = Path.Combine(packageRoot, "Data", "file.ini"); + File.SetAttributes(packageSourcePath, FileAttributes.ReadOnly | FileAttributes.Hidden); + FakeHardLinkCreator hardLinks = new(); + FileSystemDeploymentService service = CreateService(hardLinks); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + + try + { + DeploymentResult prepareResult = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + prepareResult.Succeeded.Should().BeTrue(); + hardLinks.CreatedLinks.Should().BeEmpty(); + + DeploymentResult cleanupResult = service.Cleanup(paths, CancellationToken.None); + + cleanupResult.Succeeded.Should().BeTrue(); + File.Exists(targetPath).Should().BeFalse(); + File.GetAttributes(packageSourcePath).Should().HaveFlag(FileAttributes.ReadOnly); + File.GetAttributes(packageSourcePath).Should().HaveFlag(FileAttributes.Hidden); + } + finally + { + if (File.Exists(packageSourcePath)) + { + File.SetAttributes(packageSourcePath, FileAttributes.Normal); + } + } + } + + [Fact] + public void Cleanup_LeavesModifiedDeployedFileAndOriginalBackupUntouched() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + File.WriteAllText(targetPath, "user-change"); + + DeploymentResult result = service.Cleanup(paths, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("user-change"); + Directory.EnumerateFiles( + Path.Combine(paths.DeploymentDirectory, "Backups"), + "*", + SearchOption.AllDirectories) + .Should() + .ContainSingle(path => File.ReadAllText(path) == "original"); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeTrue(); + } + + [Fact] + public void Cleanup_DoesNotDeleteModifiedDeploymentWithoutOriginalBackup() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + File.WriteAllText(targetPath, "user-change"); + + DeploymentResult result = service.Cleanup(paths, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("user-change"); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeTrue(); + } + + [Fact] + public void Cleanup_DoesNotDeleteRestoredOriginalWhenManifestWasAlreadyApplied() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + string activeManifestPath = Path.Combine(paths.DeploymentDirectory, "active.json"); + string staleManifest = File.ReadAllText(activeManifestPath); + service.Cleanup(paths, CancellationToken.None); + File.WriteAllText(activeManifestPath, staleManifest); + + DeploymentResult result = service.Cleanup(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + } + + [Fact] + public void Cleanup_RemovesCreatedDirectoriesOnlyWhenEmpty() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/Sub/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + File.WriteAllText(Path.Combine(paths.GameDirectory, "Data", "keep.txt"), "user"); + + DeploymentResult result = service.Cleanup(paths, CancellationToken.None); + + 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 void Cleanup_RemovesEveryCreatedDirectoryDeepestFirstWhenNothingWasLeftBehind() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/Sub/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + DeploymentResult result = service.Cleanup(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + Directory.Exists(Path.Combine(paths.GameDirectory, "Data", "Sub")).Should().BeFalse(); + Directory.Exists(Path.Combine(paths.GameDirectory, "Data")).Should().BeFalse(); + } + + [Fact] + public void Prepare_DeploysGibSourceAsBigTarget() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("PatchData.gib", "archive")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + 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 void Prepare_LetsHigherPrecedencePackageWinTargetConflict() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string modRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + string addonRoot = CreatePackage(paths, "Addon", ("Data/file.ini", "addon")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Prepare( + paths, + new[] + { + CreateDeploymentPackage(modRoot, 0), + CreateDeploymentPackage(addonRoot, 1) + }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().Be("addon"); + } + + [Fact] + public void Prepare_DisablesExistingRequestedFilesAndCleanupRestoresThem() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + 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 { CanCreateHardLinks = false }); + + DeploymentResult prepareResult = service.Prepare( + paths, + Array.Empty(), + new[] + { + "Data/Scripts/MultiplayerScripts.scb", + "Data/Scripts/SkirmishScripts.scb", + "Data/Scripts/Scripts.ini" + }, + CancellationToken.None); + + prepareResult.Succeeded.Should().BeTrue(); + File.Exists(multiplayerScripts).Should().BeFalse(); + File.Exists(Path.Combine(scriptsDirectory, "SkirmishScripts.scb")).Should().BeFalse(); + File.Exists(scriptsIni).Should().BeFalse(); + + DeploymentResult cleanupResult = service.Cleanup(paths, CancellationToken.None); + + cleanupResult.Succeeded.Should().BeTrue(); + File.ReadAllText(multiplayerScripts).Should().Be("multiplayer"); + File.ReadAllText(scriptsIni).Should().Be("scripts"); + } + + [Fact] + public void Prepare_NormalizesAndDeduplicatesDisabledTargets() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string scriptsDirectory = Path.Combine(paths.GameDirectory, "Data", "Scripts"); + Directory.CreateDirectory(scriptsDirectory); + string scriptsIni = Path.Combine(scriptsDirectory, "Scripts.ini"); + File.WriteAllText(scriptsIni, "scripts"); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Prepare( + paths, + Array.Empty(), + new[] { @"Data\Scripts\Scripts.ini", "Data/Scripts/Scripts.ini", " Data/Scripts/Scripts.ini ", " " }, + CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.Exists(scriptsIni).Should().BeFalse(); + } + + [Fact] + public void Prepare_ReusesDisabledFileBackupWhenPackageDeploysSameTarget() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + 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 { CanCreateHardLinks = false }); + + DeploymentResult prepareResult = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + new[] { "Data/Scripts/Scripts.ini" }, + CancellationToken.None); + + prepareResult.Succeeded.Should().BeTrue(); + File.ReadAllText(scriptsIni).Should().Be("mod"); + + DeploymentResult cleanupResult = service.Cleanup(paths, CancellationToken.None); + + cleanupResult.Succeeded.Should().BeTrue(); + File.ReadAllText(scriptsIni).Should().Be("original"); + } + + [Fact] + public void Prepare_RecoversPartialDeploymentWhenLaterFileFails() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + 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 { CanCreateHardLinks = false }); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + result.Failures.Should().ContainSingle().Which.Should().Match(failure => + failure.Kind == DeploymentFailureKind.FileSystem && + failure.Path == paths.GameDirectory); + 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 void Prepare_DoesNotTranslateCancellationIntoFailure() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + using var cancellationSource = new CancellationTokenSource(); + cancellationSource.Cancel(); + + Action act = () => service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + cancellationSource.Token); + + act.Should().Throw(); + } + + [Fact] + public void Prepare_CancellationAfterMutation_RecoversPartialDeploymentBeforeRethrowing() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage( + paths, + "Mod", + ("A/first.ini", "first"), + ("B/second.ini", "second")); + using var cancellationSource = new CancellationTokenSource(); + FileSystemDeploymentService service = CreateService( + new FakeHardLinkCreator { CanCreateHardLinks = false, CancelOn = cancellationSource }); + + Action act = () => service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + cancellationSource.Token); + + act.Should().Throw(); + File.Exists(Path.Combine(paths.GameDirectory, "A", "first.ini")).Should().BeFalse(); + File.Exists(Path.Combine(paths.GameDirectory, "B", "second.ini")).Should().BeFalse(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeFalse(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public void Prepare_CancellationDuringFinalFile_RecoversBeforeRethrowing() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + using var cancellationSource = new CancellationTokenSource(); + FileSystemDeploymentService service = CreateService( + new FakeHardLinkCreator { CancelOn = cancellationSource }); + + Action act = () => service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + cancellationSource.Token); + + act.Should().Throw(); + File.Exists(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().BeFalse(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeFalse(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public void Prepare_FailsWhenDeploymentLockIsHeld() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string deploymentRoot = paths.DeploymentDirectory; + 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 { CanCreateHardLinks = false }); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.Exists(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().BeFalse(); + } + + [SymbolicLinkFact] + public void Prepare_FailsWhenDeploymentLockIsDanglingSymbolicLink() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + SymbolicLinkTestSupport.CreateFileLink( + Path.Combine(paths.DeploymentDirectory, "deployment.lock"), + Path.Combine(directory.Path, "missing-lock-target")); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.Exists(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().BeFalse(); + } + + [SymbolicLinkFact] + public void Prepare_FailsWhenDeploymentJournalIsDanglingSymbolicLink() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + SymbolicLinkTestSupport.CreateFileLink( + Path.Combine(paths.DeploymentDirectory, "journal.jsonl"), + Path.Combine(directory.Path, "missing-journal-target")); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.Exists(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().BeFalse(); + } + + [Fact] + public void Prepare_FailsWhenPackageTreeContainsReparsePoint() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + 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); + ReparsePointTestSupport.CreateDirectoryJunction(linkPath, linkTarget); + + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + } + + [Fact] + public void Prepare_FailsWhenGameTargetParentIsReparsePoint() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + 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); + ReparsePointTestSupport.CreateDirectoryJunction(linkedDataDirectory, linkedTarget); + + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.Exists(Path.Combine(linkedTarget, "file.ini")).Should().BeFalse(); + } + + [Theory] + [InlineData(DeploymentEntryPoint.Cleanup)] + [InlineData(DeploymentEntryPoint.Recover)] + public void CleanupAndRecover_JournalWithoutActiveManifest_RestoreTheBackedUpFile( + DeploymentEntryPoint entryPoint) + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string deploymentRoot = paths.DeploymentDirectory; + string backupPath = Path.Combine(deploymentRoot, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + File.WriteAllText(backupPath, "original"); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileBackedUp( + "Data/file.ini", + "Backups/crash/Data/file.ini", + DeploymentJournalWriter.FingerprintFrom("original"), + "Backups/crash/Data/file.ini.partial")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = entryPoint == DeploymentEntryPoint.Cleanup + ? service.Cleanup(paths, CancellationToken.None) + : service.Recover(paths, CancellationToken.None); + + 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 void Recover_RestoresBackupStartedFileWhenMoveCompletedBeforeBackedUpJournal() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string deploymentRoot = paths.DeploymentDirectory; + string backupPath = Path.Combine(deploymentRoot, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileBackupStarted( + "Data/file.ini", + "Backups/crash/Data/file.ini", + "Backups/crash/Data/file.ini.partial")); + File.Move(targetPath, backupPath); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + 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 void Recover_IgnoresBackupStartedRecordWhenBackupWasNotCreated() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string deploymentRoot = paths.DeploymentDirectory; + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileBackupStarted( + "Data/file.ini", + "Backups/crash/Data/file.ini", + "Backups/crash/Data/file.ini.partial")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + File.Exists(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public void Recover_RestoresBackupWhenCleanupRestoreStartedAndBackupStillExists() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "mod"); + string deploymentRoot = paths.DeploymentDirectory; + string backupPath = Path.Combine(deploymentRoot, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + File.WriteAllText(backupPath, "original"); + string backupStagingPath = backupPath + ".partial"; + string deployStagingPath = Path.Combine(paths.GameDirectory, "Data", ".file.ini.deploy.tmp"); + string restoreStagingPath = Path.Combine(paths.GameDirectory, "Data", ".file.ini.restore.tmp"); + File.WriteAllText(backupStagingPath, "partial backup"); + File.WriteAllText(deployStagingPath, "incomplete deployment"); + File.WriteAllText(restoreStagingPath, "original"); + DeploymentFileFingerprint originalFingerprint = DeploymentJournalWriter.FingerprintFrom("original"); + DeploymentFileFingerprint modFingerprint = DeploymentJournalWriter.FingerprintFrom("mod"); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileBackedUp( + "Data/file.ini", + "Backups/crash/Data/file.ini", + originalFingerprint, + "Backups/crash/Data/file.ini.partial"), + DeploymentJournalRecord.FileDeployed( + "Data/file.ini", + DeploymentMethod.Copy, + "Backups/crash/Data/file.ini", + modFingerprint, + originalFingerprint, + "Data/.file.ini.deploy.tmp"), + DeploymentJournalRecord.FileCleanupRestoreStarted( + "Data/file.ini", + "Backups/crash/Data/file.ini", + "Data/.file.ini.restore.tmp")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + File.Exists(backupPath).Should().BeFalse(); + File.Exists(backupStagingPath).Should().BeFalse(); + File.Exists(deployStagingPath).Should().BeFalse(); + File.Exists(restoreStagingPath).Should().BeFalse(); + File.Exists(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public void Recover_TreatsMissingBackupAfterCleanupRestoreStartedAsRestored() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string deploymentRoot = paths.DeploymentDirectory; + File.WriteAllText(Path.Combine(deploymentRoot, "active.json"), "{not-json"); + DeploymentFileFingerprint originalFingerprint = DeploymentJournalWriter.FingerprintFrom("original"); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileBackedUp( + "Data/file.ini", + "Backups/crash/Data/file.ini", + originalFingerprint, + "Backups/crash/Data/file.ini.partial"), + DeploymentJournalRecord.FileDeployed( + "Data/file.ini", + DeploymentMethod.Copy, + "Backups/crash/Data/file.ini", + DeploymentJournalWriter.FingerprintFrom("mod"), + originalFingerprint, + "Data/.file.ini.deploy.tmp"), + DeploymentJournalRecord.FileCleanupRestoreStarted( + "Data/file.ini", + "Backups/crash/Data/file.ini", + "Data/.file.ini.restore.tmp")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + 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 void Recover_KeepsNoBackupFileDeletedWhenCleanupDeleteCompleted() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + string deploymentRoot = paths.DeploymentDirectory; + File.WriteAllText(Path.Combine(deploymentRoot, "active.json"), "{not-json"); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileDeployed( + "Data/file.ini", + DeploymentMethod.Copy, + null, + DeploymentJournalWriter.FingerprintFrom("mod"), + null, + "Data/.file.ini.deploy.tmp"), + DeploymentJournalRecord.FileCleanupDeleted("Data/file.ini")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + 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 void Recover_SkipsTruncatedTrailingJournalRecordAndReplaysPriorDurableState() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string deploymentRoot = paths.DeploymentDirectory; + string backupPath = Path.Combine(deploymentRoot, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + File.WriteAllText(backupPath, "original"); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileBackedUp( + "Data/file.ini", + "Backups/crash/Data/file.ini", + DeploymentJournalWriter.FingerprintFrom("original"), + "Backups/crash/Data/file.ini.partial")); + string journalPath = Path.Combine(deploymentRoot, "journal.jsonl"); + File.AppendAllText(journalPath, "{\"action\":\"file-deployed\""); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().Be("original"); + File.Exists(backupPath).Should().BeFalse(); + File.Exists(journalPath).Should().BeFalse(); + } + + [Fact] + public void Recover_WhenManifestAndJournalAreUnreadable_FailsClosedAndPreservesState() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "user data"); + string manifestPath = Path.Combine(paths.DeploymentDirectory, "active.json"); + string journalPath = Path.Combine(paths.DeploymentDirectory, "journal.jsonl"); + File.WriteAllText(manifestPath, "{not-json"); + File.WriteAllText(journalPath, "{also-not-json"); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + result.Failures.Should().ContainSingle(failure => failure.Kind == DeploymentFailureKind.Manifest); + File.ReadAllText(targetPath).Should().Be("user data"); + File.Exists(manifestPath).Should().BeTrue(); + File.Exists(journalPath).Should().BeTrue(); + } + + [Fact] + public void Recover_EmptyJournal_SucceedsAndDeletesJournal() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "user data"); + string journalPath = Path.Combine(paths.DeploymentDirectory, "journal.jsonl"); + File.WriteAllText(journalPath, string.Empty); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("user data"); + File.Exists(journalPath).Should().BeFalse(); + } + + [Fact] + public void Recover_NullManifest_FailsAndPreservesManifest() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "user data"); + string manifestPath = Path.Combine(paths.DeploymentDirectory, "active.json"); + File.WriteAllText(manifestPath, "null"); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("user data"); + File.Exists(manifestPath).Should().BeTrue(); + } + + [Theory] + [InlineData("Backups/crash/Data/file.ini.partial", true)] + [InlineData("", false)] + public void Recover_BackupStartedWithoutCompletedBackup_CleansRecordedPartialStagingFile( + string stagingRelativePath, + bool createStagingFile) + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string stagingPath = Path.Combine(paths.DeploymentDirectory, "Backups", "crash", "Data", "file.ini.partial"); + if (createStagingFile) + { + Directory.CreateDirectory(Path.GetDirectoryName(stagingPath)!); + File.WriteAllText(stagingPath, "incomplete backup"); + File.SetAttributes(stagingPath, File.GetAttributes(stagingPath) | FileAttributes.ReadOnly); + } + + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileBackupStarted( + "Data/file.ini", + "Backups/crash/Data/file.ini", + stagingRelativePath)); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + File.Exists(stagingPath).Should().BeFalse(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public void Recover_AcceptsSchemaTwoManifestWithRetiredReportingFields() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None).Succeeded.Should().BeTrue(); + string activeManifestPath = Path.Combine(paths.DeploymentDirectory, "active.json"); + JsonObject manifest = JsonNode.Parse(File.ReadAllText(activeManifestPath))!.AsObject(); + manifest["schemaVersion"]!.GetValue().Should().Be(2); + manifest["createdAtUtc"] = DateTimeOffset.UtcNow; + JsonObject file = manifest["files"]!.AsArray()[0]!.AsObject(); + file["sourcePath"] = Path.Combine(packageRoot, "Data", "file.ini"); + file["packageId"] = "mod::mod:1.0"; + file["size"] = 3; + file["lastWriteTimeUtc"] = DateTime.UtcNow; + File.WriteAllText(activeManifestPath, manifest.ToJsonString()); + File.Delete(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.Exists(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().BeFalse(); + File.Exists(activeManifestPath).Should().BeFalse(); + } + + [Fact] + public void Recover_AcceptsRetiredJournalFieldsWhenActiveManifestIsCorrupt() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string deployedPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, "mod"); + string deploymentRoot = paths.DeploymentDirectory; + File.WriteAllText(Path.Combine(deploymentRoot, "active.json"), "{not-json"); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileDeployed( + "Data/file.ini", + DeploymentMethod.Copy, + null, + DeploymentJournalWriter.FingerprintFrom("mod"), + null, + "Data/.file.ini.deploy.tmp")); + string journalPath = Path.Combine(deploymentRoot, "journal.jsonl"); + string[] journalLines = File.ReadAllLines(journalPath); + JsonObject deployedRecord = JsonNode.Parse(journalLines[1])!.AsObject(); + deployedRecord["sourcePath"] = "source"; + deployedRecord["packageId"] = "Mod"; + journalLines[1] = deployedRecord.ToJsonString(); + File.WriteAllLines(journalPath, journalLines); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.Exists(deployedPath).Should().BeFalse(); + File.Exists(Path.Combine(deploymentRoot, "active.json")).Should().BeFalse(); + File.Exists(journalPath).Should().BeFalse(); + } + + [Fact] + public void Recover_RemovesFileFromStartedJournalRecord() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string deployedPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + File.WriteAllText(deployedPath, "mod"); + string deploymentRoot = paths.DeploymentDirectory; + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileDeploymentStarted( + "Data/file.ini", + null, + DeploymentJournalWriter.FingerprintFrom("mod"), + null, + "Data/.file.ini.deploy.tmp")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.Exists(deployedPath).Should().BeFalse(); + File.Exists(Path.Combine(deploymentRoot, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public void Recover_StartedJournalRecordForReadOnlyLink_KeepsThePackageFileReadOnly() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + string packageSourcePath = Path.Combine(packageRoot, "Data", "file.ini"); + string deployedPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(deployedPath)!); + new WindowsHardLinkCreator().TryCreateHardLink(deployedPath, packageSourcePath).Should().BeTrue(); + File.SetAttributes(packageSourcePath, FileAttributes.ReadOnly); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileDeploymentStarted( + "Data/file.ini", + null, + DeploymentJournalWriter.FingerprintFrom("mod"), + null, + "Data/.file.ini.deploy.tmp")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + try + { + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.GetAttributes(packageSourcePath).Should().HaveFlag(FileAttributes.ReadOnly); + File.ReadAllText(packageSourcePath).Should().Be("mod"); + } + finally + { + File.SetAttributes(packageSourcePath, FileAttributes.Normal); + } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void Recover_ReplaysDirectoryCreationIntent(bool directoryWasCreated) + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string createdDirectoryPath = Path.Combine(paths.GameDirectory, "Data", "Sub"); + if (directoryWasCreated) + { + Directory.CreateDirectory(createdDirectoryPath); + } + + DeploymentJournalWriter.Write(paths, DeploymentJournalRecord.DirectoryCreated("Data/Sub")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + Directory.Exists(createdDirectoryPath).Should().BeFalse(); + File.Exists(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")).Should().BeFalse(); + } + + [Fact] + public void Recover_RefusesJournalBoundToDifferentPhysicalGameDirectory() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "mod"); + string otherGameDirectory = Path.Combine(directory.Path, "OtherGame"); + Directory.CreateDirectory(otherGameDirectory); + DeploymentJournalRecord[] records = + [ + DeploymentJournalRecord.DeploymentStarted( + "crash", + PhysicalDirectoryPath.ResolveExisting(otherGameDirectory), + DeploymentStateStore.GetGameRootIdentity(otherGameDirectory), + SupportedGame.Generals), + DeploymentJournalRecord.FileDeployed( + "Data/file.ini", + DeploymentMethod.Copy, + null, + DeploymentJournalWriter.FingerprintFrom("mod"), + null, + "Data/.file.ini.deploy.tmp") + ]; + var serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); + File.WriteAllLines( + Path.Combine(paths.DeploymentDirectory, "journal.jsonl"), + records.Select(record => JsonSerializer.Serialize(record, serializerOptions))); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("mod"); + File.Exists(Path.Combine(paths.DeploymentDirectory, "journal.jsonl")).Should().BeTrue(); + } + + [Theory] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData(" ", false)] + [InlineData("crash", true)] + public void CreatePaths_DeploymentIdShape_SelectsExpectedBackupOwnership( + string? deploymentId, + bool expectDeploymentSubdirectory) + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string backupRoot = Path.Combine(paths.DeploymentDirectory, DeploymentStateStore.BackupsDirectoryName); + string expectedBackupPath = expectDeploymentSubdirectory + ? Path.Combine(backupRoot, deploymentId!) + : backupRoot; + + DeploymentStatePaths statePaths = DeploymentStateStore.CreatePaths(paths, deploymentId!); + + statePaths.BackupDirectory.Should().Be(expectedBackupPath); + } + + [Theory] + [InlineData("schema")] + [InlineData("schema-older")] + [InlineData("game")] + [InlineData("game-root")] + [InlineData("game-root-identity")] + public void Recover_StateBindingMismatch_FailsBeforeMutatingGameFile(string mismatch) + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "mod"); + string gameRoot = PhysicalDirectoryPath.ResolveExisting(paths.GameDirectory); + string gameRootIdentity = DeploymentStateStore.GetGameRootIdentity(paths.GameDirectory); + var manifest = new DeploymentManifestDocument( + DeploymentStateStore.CurrentSchemaVersion, + "active", + [ + new DeploymentFileDocument( + "Data/file.ini", + DeploymentMethod.Copy, + null, + DeploymentJournalWriter.FingerprintFrom("mod")) + ], + Array.Empty(), + gameRoot, + gameRootIdentity, + paths.Game); + string otherGameRoot = directory.CreateDirectory("OtherGame"); + manifest = mismatch switch + { + "schema" => manifest with { SchemaVersion = DeploymentStateStore.CurrentSchemaVersion + 1 }, + "schema-older" => manifest with { SchemaVersion = DeploymentStateStore.CurrentSchemaVersion - 1 }, + "game" => manifest with { Game = SupportedGame.ZeroHour }, + "game-root" => manifest with { GameRoot = PhysicalDirectoryPath.ResolveExisting(otherGameRoot) }, + "game-root-identity" => manifest with { GameRootIdentity = "00000000:0000000000000000" }, + _ => throw new ArgumentOutOfRangeException(nameof(mismatch), mismatch, null) + }; + DeploymentStatePaths statePaths = DeploymentStateStore.CreatePaths(paths, string.Empty); + DeploymentStateStore.WriteManifest(statePaths.ActiveManifestPath, manifest); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("mod"); + File.Exists(statePaths.ActiveManifestPath).Should().BeTrue(); + } + + [SymbolicLinkFact] + public void Recover_LinkedJournal_RejectsBeforeReplayingExternalState() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "mod"); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileDeployed( + "Data/file.ini", + DeploymentMethod.Copy, + null, + DeploymentJournalWriter.FingerprintFrom("mod"), + null, + "Data/.file.ini.deploy.tmp")); + string journalPath = Path.Combine(paths.DeploymentDirectory, "journal.jsonl"); + string externalJournalPath = directory.GetPath("external-journal.jsonl"); + File.Move(journalPath, externalJournalPath); + SymbolicLinkTestSupport.CreateFileLink(journalPath, externalJournalPath); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("mod"); + File.Exists(externalJournalPath).Should().BeTrue(); + } + + [SymbolicLinkFact] + public void Recover_LinkedManifest_RejectsBeforeFallingBackToJournal() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "mod"); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileDeployed( + "Data/file.ini", + DeploymentMethod.Copy, + null, + DeploymentJournalWriter.FingerprintFrom("mod"), + null, + "Data/.file.ini.deploy.tmp")); + string manifestPath = Path.Combine(paths.DeploymentDirectory, "active.json"); + string externalManifestPath = directory.CreateFile("external-manifest.json", "null"); + SymbolicLinkTestSupport.CreateFileLink(manifestPath, externalManifestPath); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("mod"); + File.ReadAllText(externalManifestPath).Should().Be("null"); + } + + [Fact] + public void Recover_RefusesActiveManifestForDifferentGameRootWhenJournalIsEmpty() + { + using var directory = new TestDirectory(); + LauncherPaths originalPaths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(originalPaths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + DeploymentResult prepareResult = service.Prepare( + originalPaths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + prepareResult.Succeeded.Should().BeTrue(); + File.WriteAllText(Path.Combine(originalPaths.DeploymentDirectory, "journal.jsonl"), string.Empty); + + string otherGameDirectory = Path.Combine(directory.Path, "OtherGame"); + string otherTargetPath = Path.Combine(otherGameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(otherTargetPath)!); + File.WriteAllText(otherTargetPath, "mod"); + LauncherPaths otherPaths = new LauncherStoragePaths(Path.Combine(directory.Path, "Launcher")) + .CreateGamePaths(SupportedGame.Generals, otherGameDirectory); + + DeploymentResult result = service.Recover(otherPaths, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.ReadAllText(otherTargetPath).Should().Be("mod"); + File.Exists(Path.Combine(originalPaths.DeploymentDirectory, "active.json")).Should().BeTrue(); + } + + [Fact] + public void Prepare_WhenTheDeploymentStateDirectoryIsMissing_CreatesItAndDeploys() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + Directory.Delete(paths.DeploymentDirectory, true); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.ReadAllText(Path.Combine(paths.GameDirectory, "Data", "file.ini")).Should().Be("mod"); + } + + [Fact] + public void Prepare_WhenTheTargetAppearsWhileStaging_FailsWithoutOverwritingIt() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + FakeHardLinkCreator hardLinks = new() + { + CreateHook = (_, _) => + { + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, "written by the game"); + } + }; + FileSystemDeploymentService service = CreateService(hardLinks); + + DeploymentResult result = service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("written by the game"); + } + + [Fact] + public void Cleanup_RestoresTheOriginalOverAnUntouchedDeployedHardLink() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new WindowsHardLinkCreator()); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None) + .Succeeded.Should().BeTrue(); + + DeploymentResult result = service.Cleanup(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + } + + [Fact] + public void Cleanup_WhenTheDeployedHardLinkWasReplacedWithMatchingBytes_KeepsTheBackedUpOriginal() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new WindowsHardLinkCreator()); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None) + .Succeeded.Should().BeTrue(); + File.Delete(targetPath); + File.WriteAllText(targetPath, "mod"); + + DeploymentResult result = service.Cleanup(paths, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("mod"); + File.Exists(Path.Combine(paths.DeploymentDirectory, "active.json")).Should().BeTrue(); + } + + [Fact] + public void Cleanup_WhenTheDeployedCopyAlreadyHoldsTheOriginalBytes_RestoresTheBackup() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "original"); + string packageRoot = CreatePackage(paths, "Mod", ("Data/file.ini", "mod")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + service.Prepare( + paths, + new[] { CreateDeploymentPackage(packageRoot, 0) }, + Array.Empty(), + CancellationToken.None) + .Succeeded.Should().BeTrue(); + File.WriteAllText(targetPath, "original"); + + DeploymentResult result = service.Cleanup(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + Directory.Exists(Path.Combine(paths.DeploymentDirectory, "Backups")).Should().BeFalse(); + } + + [Fact] + public void Recover_StartedDeploymentOverABackedUpFile_RestoresTheOriginal() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "mod"); + string backupPath = Path.Combine(paths.DeploymentDirectory, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + File.WriteAllText(backupPath, "original"); + DeploymentFileFingerprint originalFingerprint = DeploymentJournalWriter.FingerprintFrom("original"); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileBackedUp( + "Data/file.ini", + "Backups/crash/Data/file.ini", + originalFingerprint, + string.Empty), + DeploymentJournalRecord.FileDeploymentStarted( + "Data/file.ini", + "Backups/crash/Data/file.ini", + DeploymentJournalWriter.FingerprintFrom("mod"), + originalFingerprint, + "Data/.file.ini.deploy.tmp")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("original"); + } + + [Fact] + public void Recover_StartedDeploymentWhoseTargetWasModified_LeavesTheGameFileUntouched() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "user edited"); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileDeploymentStarted( + "Data/file.ini", + null, + DeploymentJournalWriter.FingerprintFrom("mod"), + null, + "Data/.file.ini.deploy.tmp")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeFalse(); + File.ReadAllText(targetPath).Should().Be("user edited"); + } + + [Fact] + public void Recover_RemovesAReadOnlyRestoreStagingFileLeftByAnInterruptedCleanup() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + string targetPath = CreateExistingGameFile(paths, "mod"); + string backupPath = Path.Combine(paths.DeploymentDirectory, "Backups", "crash", "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(backupPath)!); + File.WriteAllText(backupPath, "original"); + string restoreStagingPath = Path.Combine(paths.GameDirectory, "Data", ".file.ini.restore.tmp"); + File.WriteAllText(restoreStagingPath, "original"); + File.SetAttributes(restoreStagingPath, FileAttributes.ReadOnly); + DeploymentFileFingerprint originalFingerprint = DeploymentJournalWriter.FingerprintFrom("original"); + DeploymentJournalWriter.Write( + paths, + DeploymentJournalRecord.FileBackedUp( + "Data/file.ini", + "Backups/crash/Data/file.ini", + originalFingerprint, + string.Empty), + DeploymentJournalRecord.FileDeployed( + "Data/file.ini", + DeploymentMethod.Copy, + "Backups/crash/Data/file.ini", + DeploymentJournalWriter.FingerprintFrom("mod"), + originalFingerprint, + "Data/.file.ini.deploy.tmp"), + DeploymentJournalRecord.FileCleanupRestoreStarted( + "Data/file.ini", + "Backups/crash/Data/file.ini", + "Data/.file.ini.restore.tmp")); + FileSystemDeploymentService service = CreateService(new FakeHardLinkCreator { CanCreateHardLinks = false }); + + try + { + DeploymentResult result = service.Recover(paths, CancellationToken.None); + + result.Succeeded.Should().BeTrue(); + File.Exists(restoreStagingPath).Should().BeFalse(); + } + finally + { + if (File.Exists(restoreStagingPath)) + { + File.SetAttributes(restoreStagingPath, FileAttributes.Normal); + } + } + } + + /// + /// Writes the file the deployment tests target into the game folder, standing in for content a user + /// already has there before launch preparation runs. + /// + private static string CreateExistingGameFile(LauncherPaths paths, string contents) + { + string targetPath = Path.Combine(paths.GameDirectory, "Data", "file.ini"); + Directory.CreateDirectory(Path.GetDirectoryName(targetPath)!); + File.WriteAllText(targetPath, contents); + return targetPath; + } + + private static FileSystemDeploymentService CreateService(IHardLinkCreator hardLinkCreator) + { + return new FileSystemDeploymentService( + hardLinkCreator, + NullLogger.Instance); + } + + 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 DeploymentPackage CreateDeploymentPackage( + string root, + int precedence) + { + return new DeploymentPackage(root, precedence); + } + + /// + /// The two entry points that replay the same persisted deployment state. + /// + public enum DeploymentEntryPoint + { + Cleanup, + Recover + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionServiceTests.cs new file mode 100644 index 00000000..cd34b46c --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityResolutionServiceTests.cs @@ -0,0 +1,1065 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; +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.Infrastructure.Integrity.Contracts; +using GenLauncherGO.Infrastructure.Launching.Contracts; +using GenLauncherGO.Infrastructure.Launching.Services; +using GenLauncherGO.Infrastructure.Remote.Contracts; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class FileSystemLaunchContentIntegrityResolutionServiceTests +{ + [Fact] + public async Task VerifyAsync_BuildsTargetsAndVerifiesThemAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateVersion(ContentSourceKind.ManagedSingleFile); + ContentIntegrityTarget target = CreateTarget("package", Path.Combine(paths.ModsDirectory, "ShockWave", "1.2")); + LaunchContentIntegrityTargetContext[] contexts = + [ + new(target, version, false) + ]; + var report = new ContentIntegrityReport(Array.Empty()); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + integrityService.VerificationReport = report; + var targetBuilder = new StubLaunchContentIntegrityTargetBuilder(contexts); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + targetBuilder); + var request = new LaunchContentIntegrityTargetRequest( + paths, + new[] { version }, + new[] { version }, + "cache"); + + LaunchContentIntegrityVerificationResult result = await service.VerifyAsync( + request, + CancellationToken.None); + + result.Report.Should().BeSameAs(report); + result.TargetContexts.Should().Equal(contexts); + integrityService.VerifiedTargetSets.Should().ContainSingle(targets => + targets.Count == 1 && + targets[0] == target); + integrityService.VerifiedPaths.Should().ContainSingle().Which.Should().Be(paths); + } + + [Theory] + [InlineData("https://cdn.example.test/card.jpg", "1.2.jpg")] + [InlineData("https://cdn.example.test/card.webp", "1.2.png")] + public async Task InitializeUntrackedManagedCachesAsync_CapturesCacheWhenExpectedAssetsMatchAsync( + string imageSourceLink, + string expectedImageFileName) + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateVersion( + ContentSourceKind.ManagedSingleFile, + imageSourceLink: imageSourceLink); + ContentIntegrityTarget cacheTarget = CreateTarget( + "cache", + paths.GetModificationImagesDirectory("ShockWave")); + LaunchContentIntegrityTargetContext cacheContext = new(cacheTarget, version, true); + ContentIntegrityReport report = CreateReport( + "cache", + ContentSourceKind.ManagedSingleFile, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.Redownload); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + integrityService.CaptureIfMatchesExpectedFileSetResult = true; + FileSystemLaunchContentIntegrityResolutionService service = CreateService(integrityService); + + bool initialized = await service.InitializeUntrackedManagedCachesAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { cacheContext }), + CancellationToken.None); + + initialized.Should().BeTrue(); + integrityService.ConditionalSnapshotRequests.Should().ContainSingle(request => + request.Target == cacheTarget && + request.ExpectedRelativePaths.SetEquals(new[] { expectedImageFileName })); + integrityService.ConditionalSnapshotPaths.Should().ContainSingle().Which.Should().Be(paths); + } + + [Fact] + public async Task InitializeUntrackedManagedCachesAsync_WhenCacheAlsoCarriesAnotherIssue_LeavesItForResolutionAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateVersion(ContentSourceKind.ManagedSingleFile); + ContentIntegrityTarget cacheTarget = CreateTarget( + "cache", + paths.GetModificationImagesDirectory("ShockWave")); + LaunchContentIntegrityTargetContext cacheContext = new(cacheTarget, version, true); + ContentIntegrityReport report = CreateReport( + CreateIssue("cache", ContentSourceKind.ManagedSingleFile, IntegrityIssueKind.Untracked, + IntegrityIssueAction.Redownload), + CreateIssue("cache", ContentSourceKind.ManagedSingleFile, IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.Repair)); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + integrityService.CaptureIfMatchesExpectedFileSetResult = true; + FileSystemLaunchContentIntegrityResolutionService service = CreateService(integrityService); + + bool initialized = await service.InitializeUntrackedManagedCachesAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { cacheContext }), + CancellationToken.None); + + initialized.Should().BeFalse(); + integrityService.ConditionalSnapshotRequests.Should().BeEmpty(); + } + + [Fact] + public async Task InitializeUntrackedManagedCachesAsync_WhenCacheContentsDoNotMatch_ReportsNoInitializationAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateVersion(ContentSourceKind.ManagedSingleFile); + ContentIntegrityTarget cacheTarget = CreateTarget( + "cache", + paths.GetModificationImagesDirectory("ShockWave")); + LaunchContentIntegrityTargetContext cacheContext = new(cacheTarget, version, true); + ContentIntegrityReport report = CreateReport( + "cache", + ContentSourceKind.ManagedSingleFile, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.Redownload); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + integrityService.CaptureIfMatchesExpectedFileSetResult = false; + FileSystemLaunchContentIntegrityResolutionService service = CreateService(integrityService); + + bool initialized = await service.InitializeUntrackedManagedCachesAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { cacheContext }), + CancellationToken.None); + + initialized.Should().BeFalse(); + integrityService.ConditionalSnapshotRequests.Should().ContainSingle(); + } + + [Fact] + public async Task InitializeUntrackedManagedCachesAsync_WithUntrackedPackage_LeavesItForResolutionAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateVersion(ContentSourceKind.ManagedSingleFile); + ContentIntegrityTarget packageTarget = CreateTarget( + "package", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2")); + LaunchContentIntegrityTargetContext packageContext = new(packageTarget, version, false); + ContentIntegrityReport report = CreateReport( + "package", + ContentSourceKind.ManagedSingleFile, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.Redownload); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + integrityService.CaptureIfMatchesExpectedFileSetResult = true; + FileSystemLaunchContentIntegrityResolutionService service = CreateService(integrityService); + + bool initialized = await service.InitializeUntrackedManagedCachesAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { packageContext }), + CancellationToken.None); + + initialized.Should().BeFalse(); + integrityService.ConditionalSnapshotRequests.Should().BeEmpty(); + } + + [Fact] + public async Task ResolveAsync_RefreshesManagedCacheAndPreservesIgnoredFilesAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion 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"); + // A nested folder, so the sweep has to empty it from the deepest level upwards. Removing the child first is + // what lets its parent be empty in the same pass; the other order leaves the outer folder standing forever. + string staleDirectory = Path.Combine(cacheRoot, "Stale"); + string staleNestedDirectory = Path.Combine(staleDirectory, "Nested"); + Directory.CreateDirectory(staleNestedDirectory); + await File.WriteAllTextAsync(staleFilePath, "stale", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(ignoredFilePath, "ignored", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(staleNestedDirectory, "nested.txt"), "nested", TestContext.Current.CancellationToken); + + ContentIntegrityTarget cacheTarget = CreateTarget( + "cache", + cacheRoot, + ContentSourceKind.ManagedSingleFile, + new HashSet(StringComparer.OrdinalIgnoreCase) { "ignored.txt" }); + LaunchContentIntegrityTargetContext cacheContext = new(cacheTarget, version, true); + ContentIntegrityReport report = CreateReport( + "cache", + ContentSourceKind.ManagedSingleFile, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.Repair); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + var assetDownloader = new RecordingRemoteAssetDownloader(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + assetDownloader: assetDownloader); + RecordingProgress progress = new(); + + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { cacheContext }), + progress, + CancellationToken.None); + + File.Exists(staleFilePath).Should().BeFalse(); + File.Exists(ignoredFilePath).Should().BeTrue(); + Directory.Exists(staleNestedDirectory).Should().BeFalse(); + Directory.Exists(staleDirectory).Should().BeFalse(); + assetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == new Uri("https://cdn.example.test/card.jpg") && + call.DestinationFilePath == Path.Combine(cacheRoot, "1.2.jpg")); + progress.Reports.Should().ContainSingle(report => + report.TargetId == "cache" && + report.Completed && + report.PackageProgress == null); + integrityService.CapturedTargets.Should().ContainSingle().Which.Should().Be(cacheTarget); + } + + [Fact] + public async Task ResolveAsync_RepairsManagedSingleFilePackageAndReportsProgressAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateVersion(ContentSourceKind.ManagedSingleFile); + ContentIntegrityTarget packageTarget = CreateTarget( + "package", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2")); + LaunchContentIntegrityTargetContext packageContext = new(packageTarget, version, false); + ContentIntegrityReport report = CreateReport( + "package", + ContentSourceKind.ManagedSingleFile, + IntegrityIssueKind.MissingFile, + IntegrityIssueAction.Repair); + PackageUpdateProgress packageProgress = new(100, 40, 40, "package.zip"); + var singleFilePackageUpdater = new RecordingSingleFilePackageUpdater + { + ProgressToReport = packageProgress + }; + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + singleFilePackageUpdater: singleFilePackageUpdater); + RecordingProgress progress = new(); + + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { packageContext }), + progress, + CancellationToken.None); + + (DownloadFileMetadata Metadata, PackageUpdatePathSet Paths) updateRequest = + singleFilePackageUpdater.Requests.Should().ContainSingle().Which; + updateRequest.Metadata.DownloadUri.Should().Be(new Uri("https://www.dropbox.com/s/package/file.zip?dl=1")); + updateRequest.Paths.TemporaryPath.FullPath.Should() + .Be(Path.Combine(paths.TempDirectory, "Packages", "ShockWave", "1.2")); + updateRequest.Paths.InstalledPath.FullPath.Should().Be(packageTarget.RootDirectory); + progress.Reports.Should().ContainSingle(report => + report.TargetId == "package" && + report.PackageProgress == packageProgress && + !report.Completed); + integrityService.CapturedTargets.Should().ContainSingle().Which.Should().Be(packageTarget); + } + + [Fact] + public async Task ResolveAsync_AppliesCleanupToEveryTargetBeforeRepairingAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateVersion(ContentSourceKind.ManagedSingleFile); + ContentIntegrityTarget packageTarget = CreateTarget( + "package", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2")); + ContentIntegrityTarget cacheTarget = CreateTarget( + "cache", + paths.GetModificationImagesDirectory("ShockWave")); + LaunchContentIntegrityTargetContext[] contexts = + [ + new(packageTarget, version, false), + new(cacheTarget, version, true) + ]; + ContentIntegrityReport report = CreateReport( + "package", + ContentSourceKind.ManagedSingleFile, + IntegrityIssueKind.MissingFile, + IntegrityIssueAction.Repair); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + var singleFilePackageUpdater = new RecordingSingleFilePackageUpdater + { + Update = (_, _, _) => + { + integrityService.Calls.Add("repair"); + return Task.CompletedTask; + } + }; + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + singleFilePackageUpdater: singleFilePackageUpdater); + + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, contexts), + null, + CancellationToken.None); + + (ContentIntegrityReport Report, IReadOnlyList Targets) cleanupRequest = + integrityService.CleanupRequests.Should().ContainSingle().Which; + cleanupRequest.Report.Should().BeSameAs(report); + cleanupRequest.Targets.Should().Equal(packageTarget, cacheTarget); + integrityService.Calls.Should().Equal("cleanup", "repair", "capture"); + } + + [Fact] + public async Task ResolveAsync_TrustAsManualIssue_MarksVersionManualAndSnapshotsItAsManualAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateVersion(ContentSourceKind.UnknownLegacy); + ContentIntegrityTarget packageTarget = CreateTarget( + "package", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"), + ContentSourceKind.UnknownLegacy); + LaunchContentIntegrityTargetContext packageContext = new(packageTarget, version, false); + ContentIntegrityReport report = CreateReport( + "package", + ContentSourceKind.UnknownLegacy, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.TrustAsManual); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + var catalog = new FakeLauncherContentCatalog(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + catalog: catalog); + + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { packageContext }), + null, + CancellationToken.None); + + version.Installation.ContentSourceKind.Should().Be(ContentSourceKind.Manual); + catalog.SaveCount.Should().Be(1); + ContentIntegrityTarget capturedTarget = integrityService.CapturedTargets.Should().ContainSingle().Which; + capturedTarget.Id.Should().Be(packageTarget.Id); + capturedTarget.SourceKind.Should().Be(ContentSourceKind.Manual); + } + + [Fact] + public async Task ResolveAsync_AbsorbIssue_SnapshotsTheTargetWithoutRepairingItAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateVersion(ContentSourceKind.Manual); + ContentIntegrityTarget packageTarget = CreateTarget( + "package", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"), + ContentSourceKind.Manual); + LaunchContentIntegrityTargetContext packageContext = new(packageTarget, version, false); + ContentIntegrityReport report = CreateReport( + "package", + ContentSourceKind.Manual, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.Absorb); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + var singleFilePackageUpdater = new RecordingSingleFilePackageUpdater(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + singleFilePackageUpdater: singleFilePackageUpdater); + + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { packageContext }), + null, + CancellationToken.None); + + integrityService.CapturedTargets.Should().ContainSingle().Which.Should().BeSameAs(packageTarget); + version.Installation.ContentSourceKind.Should().Be(ContentSourceKind.Manual); + singleFilePackageUpdater.Requests.Should().BeEmpty(); + } + + [Fact] + public async Task ResolveAsync_RepairsManagedS3PackageFileInPlaceUsingManifestAndTargetRootAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = TestLauncherContent.S3Version(version: "1.2"); + ContentIntegrityTarget packageTarget = CreateTarget( + "package", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"), + ContentSourceKind.ManagedS3); + LaunchContentIntegrityTargetContext packageContext = new(packageTarget, version, false); + ContentIntegrityReport report = CreateReport( + "package", + ContentSourceKind.ManagedS3, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.Repair, + "Data/file.gib"); + RemoteFileManifestEntry[] files = + [ + new("Data/file.big", StubFileHashService.MatchingHash, 10), + new("Data/readme.txt", StubFileHashService.MatchingHash, 5) + ]; + var manifestReader = new RecordingS3ObjectManifestReader(); + manifestReader.Enqueue(files); + PackageUpdateProgress packageProgress = new(10, 10, 100, "Data/file.big"); + var s3PackageUpdater = new RecordingS3PackageUpdater { ProgressToReport = packageProgress }; + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + manifestReader: manifestReader, + s3PackageUpdater: s3PackageUpdater); + RecordingProgress progress = new(); + + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { packageContext }), + progress, + CancellationToken.None); + + manifestReader.Requests.Should().ContainSingle().Which.Should().Match(request => + request.Endpoint == TestLauncherContent.S3Host && + request.BucketName == TestLauncherContent.S3Bucket && + request.Prefix == "ShockWave/1.2"); + S3PackageFileRepairRequest repairRequest = + s3PackageUpdater.RepairRequests.Should().ContainSingle().Which; + repairRequest.Files.Should().ContainSingle().Which.FileName.Should().Be("Data/file.big"); + repairRequest.Source.Should().BeSameAs(manifestReader.Requests.Single()); + repairRequest.InstalledPath.FullPath.Should().Be(packageTarget.RootDirectory); + repairRequest.InstalledPath.OwnerRoot.Should().Be(paths.ModsDirectory); + repairRequest.HashCheckedExtensions.Should().BeEquivalentTo(".big", ".txt", ".gib"); + progress.Reports.Should().ContainSingle(report => + report.TargetId == "package" && + report.PackageProgress == packageProgress); + integrityService.CapturedTargets.Should().ContainSingle().Which.Should().Be(packageTarget); + s3PackageUpdater.UpdateRequests.Should().BeEmpty(); + } + + [Fact] + public async Task ResolveAsync_RepairsUntrackedManagedS3PackageUsingFullReplacementAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = TestLauncherContent.S3Version(version: "1.2"); + ContentIntegrityTarget packageTarget = CreateTarget( + "package", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"), + ContentSourceKind.ManagedS3); + LaunchContentIntegrityTargetContext packageContext = new(packageTarget, version, false); + ContentIntegrityReport report = CreateReport( + "package", + ContentSourceKind.ManagedS3, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.Repair, + "."); + RemoteFileManifestEntry[] files = + [ + new("Data/file.big", StubFileHashService.MatchingHash, 10), + new("Data/readme.txt", StubFileHashService.MatchingHash, 5) + ]; + var manifestReader = new RecordingS3ObjectManifestReader(); + manifestReader.Enqueue(files); + var s3PackageUpdater = new RecordingS3PackageUpdater(); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + manifestReader: manifestReader, + s3PackageUpdater: s3PackageUpdater); + + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { packageContext }), + null, + CancellationToken.None); + + S3PackageUpdateRequest updateRequest = + s3PackageUpdater.UpdateRequests.Should().ContainSingle().Which; + updateRequest.Files.Should().Equal(files); + updateRequest.Source.Should().BeSameAs(manifestReader.Requests.Single()); + updateRequest.PathSet.InstalledPath.FullPath.Should().Be(packageTarget.RootDirectory); + updateRequest.PathSet.BackupPath.FullPath.Should().Be( + Path.Combine(paths.StateDirectory, "PackageBackups", "ShockWave", "1.2")); + updateRequest.PathSet.LatestInstalledPath!.FullPath.Should().Be(packageTarget.RootDirectory); + s3PackageUpdater.RepairRequests.Should().BeEmpty(); + integrityService.CapturedTargets.Should().ContainSingle().Which.Should().Be(packageTarget); + } + + /// + /// A package holding an extra file beside a modified one cannot be mended file by file: replacing the + /// modified file leaves the untracked one exactly where it was. Any issue that is not a file-level repair + /// therefore has to send the whole package down the replacement path, even when every reported path does + /// map to a manifest entry and a partial repair would otherwise look possible. + /// + [Fact] + public async Task ResolveAsync_UntrackedFileBesideAModifiedOne_ReplacesRatherThanRepairsAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = TestLauncherContent.S3Version(version: "1.2"); + ContentIntegrityTarget packageTarget = CreateTarget( + "package", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"), + ContentSourceKind.ManagedS3); + LaunchContentIntegrityTargetContext packageContext = new(packageTarget, version, false); + ContentIntegrityReport report = CreateReport( + CreateIssue( + "package", + ContentSourceKind.ManagedS3, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.Repair, + "Data/file.gib"), + CreateIssue( + "package", + ContentSourceKind.ManagedS3, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.Repair, + "Data/readme.txt")); + RemoteFileManifestEntry[] files = + [ + new("Data/file.big", StubFileHashService.MatchingHash, 10), + new("Data/readme.txt", StubFileHashService.MatchingHash, 5) + ]; + var manifestReader = new RecordingS3ObjectManifestReader(); + manifestReader.Enqueue(files); + var s3PackageUpdater = new RecordingS3PackageUpdater(); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + manifestReader: manifestReader, + s3PackageUpdater: s3PackageUpdater); + + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { packageContext }), + null, + CancellationToken.None); + + s3PackageUpdater.RepairRequests.Should().BeEmpty( + "an untracked file survives an in-place repair, so the package has to be replaced whole"); + s3PackageUpdater.UpdateRequests.Should().ContainSingle().Which.Files.Should().Equal(files); + } + + [Fact] + public async Task RegisterManualImportAsync_MarksVersionManualAndCapturesPackageAndCacheSnapshotsAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion 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(packageTarget, version, false), + new(cacheTarget, version, true) + ]; + var targetBuilder = new StubLaunchContentIntegrityTargetBuilder(contexts); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + var catalog = new FakeLauncherContentCatalog(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + targetBuilder, + catalog: catalog); + + await service.RegisterManualImportAsync( + new LaunchContentIntegrityVersionRequest(paths, version, new[] { version }, "cache"), + CancellationToken.None); + + version.Installation.ContentSourceKind.Should().Be(ContentSourceKind.Manual); + catalog.SaveCount.Should().Be(1); + targetBuilder.Requests.Should().Contain(request => + request.Paths == paths && + request.ActiveVersions.Count == 1 && + request.ActiveVersions[0] == version && + request.CacheDisplayNameSuffix == "cache"); + integrityService.CapturedTargets.Should().BeEquivalentTo(new[] { packageTarget, cacheTarget }); + integrityService.CapturedPaths.Should().OnlyContain(capturedPaths => capturedPaths == paths); + } + + [Fact] + public async Task CaptureManagedInstallSnapshotAsync_RefreshesMismatchedThemedImageCacheAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateVersion( + ContentSourceKind.ManagedSingleFile, + theme: new LauncherContentTheme + { + GenLauncherBackgroundImageLink = "https://cdn.example.test/background.png" + }); + LauncherContentVersion inactiveVersion = CreateVersion( + ContentSourceKind.ManagedSingleFile, + "1.1"); + string cacheRoot = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(cacheRoot); + string staleFilePath = Path.Combine(cacheRoot, "stale.txt"); + string inactiveImagePath = Path.Combine(cacheRoot, "1.1.jpg"); + string themeCachePath = Path.Combine( + cacheRoot, + LauncherContentTheme.ResolveCacheBaseName("1.2") + ".yaml"); + string backgroundPath = Path.Combine(cacheRoot, LauncherContentTheme.ResolveBackgroundImageBaseName("1.2") + ".png"); + await File.WriteAllTextAsync(staleFilePath, "stale", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(inactiveImagePath, "inactive", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(themeCachePath, "theme", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(backgroundPath, "old background", TestContext.Current.CancellationToken); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + var assetDownloader = new RecordingRemoteAssetDownloader + { + Handler = (_, destinationPath, cancellationToken) => + File.WriteAllTextAsync(destinationPath, "downloaded", cancellationToken) + }; + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + new FileSystemLaunchContentIntegrityTargetBuilder(), + assetDownloader: assetDownloader); + + await service.CaptureManagedInstallSnapshotAsync( + new LaunchContentIntegrityVersionRequest( + paths, + version, + new[] { version, inactiveVersion }, + "cache"), + CancellationToken.None); + + ContentIntegrityTarget packageTarget = integrityService.CapturedTargets[0]; + packageTarget.Id.Should().Be("package:mod::shockwave:1.2"); + packageTarget.RootDirectory.Should().Be(Path.Combine(paths.ModsDirectory, "ShockWave", "1.2")); + packageTarget.SourceKind.Should().Be(ContentSourceKind.ManagedSingleFile); + + (ContentIntegrityTarget Target, IReadOnlySet ExpectedRelativePaths) conditionalSnapshot = + integrityService.ConditionalSnapshotRequests.Should().ContainSingle().Which; + conditionalSnapshot.Target.Id.Should().Be("cache:mod::shockwave:1.2"); + conditionalSnapshot.Target.RootDirectory.Should().Be(cacheRoot); + conditionalSnapshot.Target.IgnoredRelativePaths.Should().BeEquivalentTo( + "1.1.jpg", + LauncherContentTheme.ResolveCacheBaseName("1.2") + ".yaml"); + conditionalSnapshot.ExpectedRelativePaths.Should().BeEquivalentTo( + "1.2.jpg", + LauncherContentTheme.ResolveBackgroundImageBaseName("1.2") + ".png"); + + File.Exists(staleFilePath).Should().BeFalse(); + File.Exists(inactiveImagePath).Should().BeTrue(); + File.Exists(themeCachePath).Should().BeTrue(); + (await File.ReadAllTextAsync(backgroundPath, TestContext.Current.CancellationToken)).Should().Be("downloaded"); + assetDownloader.Calls.Should().BeEquivalentTo(new[] + { + ( + new Uri("https://cdn.example.test/card.jpg"), + Path.Combine(cacheRoot, "1.2.jpg")), + ( + new Uri("https://cdn.example.test/background.png"), + Path.Combine(cacheRoot, LauncherContentTheme.ResolveBackgroundImageBaseName("1.2") + ".png")) + }); + integrityService.CapturedTargets.Should().HaveCount(2); + integrityService.CapturedTargets[1].Should().Be(conditionalSnapshot.Target); + } + + [Fact] + public async Task CaptureManualImageSnapshotAsync_CapturesOnlyResolvedManualImageCacheAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateVersion( + ContentSourceKind.Manual, + simpleDownloadLink: string.Empty); + LauncherContentVersion inactiveVersion = CreateVersion( + ContentSourceKind.Manual, + "1.1", + string.Empty); + string cacheRoot = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(cacheRoot); + await File.WriteAllTextAsync(Path.Combine(cacheRoot, LauncherContentTheme.ResolveBackgroundImageBaseName("1.1") + ".png"), "inactive", TestContext.Current.CancellationToken); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + var catalog = new FakeLauncherContentCatalog(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + integrityService, + new FileSystemLaunchContentIntegrityTargetBuilder(), + catalog: catalog); + + await service.CaptureManualImageSnapshotAsync( + new LaunchContentIntegrityVersionRequest( + paths, + version, + new[] { version, inactiveVersion }, + "image cache"), + CancellationToken.None); + + ContentIntegrityTarget cacheTarget = integrityService.CapturedTargets.Should().ContainSingle().Which; + cacheTarget.Id.Should().Be("cache:mod::shockwave:1.2"); + cacheTarget.DisplayName.Should().Be("ShockWave 1.2 image cache"); + cacheTarget.RootDirectory.Should().Be(cacheRoot); + cacheTarget.SourceKind.Should().Be(ContentSourceKind.Manual); + cacheTarget.IgnoredRelativePaths.Should().ContainSingle().Which.Should().Be(LauncherContentTheme.ResolveBackgroundImageBaseName("1.1") + ".png"); + integrityService.ConditionalSnapshotRequests.Should().BeEmpty(); + catalog.SaveCount.Should().Be(0); + } + + [Fact] + public async Task InitializeUntrackedManagedCachesAsync_WithUntrackedManualCache_LeavesItForResolutionAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateVersion(ContentSourceKind.Manual); + ContentIntegrityTarget cacheTarget = CreateTarget( + "cache", + paths.GetModificationImagesDirectory("ShockWave"), + ContentSourceKind.Manual); + LaunchContentIntegrityTargetContext cacheContext = new(cacheTarget, version, true); + ContentIntegrityReport report = CreateReport( + "cache", + ContentSourceKind.Manual, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.TrustAsManual); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + integrityService.CaptureIfMatchesExpectedFileSetResult = true; + FileSystemLaunchContentIntegrityResolutionService service = CreateService(integrityService); + + bool initialized = await service.InitializeUntrackedManagedCachesAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { cacheContext }), + CancellationToken.None); + + initialized.Should().BeFalse(); + integrityService.ConditionalSnapshotRequests.Should().BeEmpty(); + } + + [Fact] + public async Task ResolveAsync_TrustAsManualIssuesForSeveralTargets_MarksEveryReportedVersionManualAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion firstVersion = CreateVersion(ContentSourceKind.UnknownLegacy); + LauncherContentVersion secondVersion = CreateVersion(ContentSourceKind.UnknownLegacy, "1.3"); + LaunchContentIntegrityTargetContext firstContext = new( + CreateTarget( + "package-first", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"), + ContentSourceKind.UnknownLegacy), + firstVersion, + false); + LaunchContentIntegrityTargetContext secondContext = new( + CreateTarget( + "package-second", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.3"), + ContentSourceKind.UnknownLegacy), + secondVersion, + false); + ContentIntegrityReport report = CreateReport( + CreateIssue( + "package-first", + ContentSourceKind.UnknownLegacy, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.TrustAsManual), + CreateIssue( + "package-second", + ContentSourceKind.UnknownLegacy, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.TrustAsManual)); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService(integrityService); + + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { firstContext, secondContext }), + null, + CancellationToken.None); + + firstVersion.Installation.ContentSourceKind.Should().Be(ContentSourceKind.Manual); + secondVersion.Installation.ContentSourceKind.Should().Be(ContentSourceKind.Manual); + integrityService.CapturedTargets.Select(target => target.Id).Should() + .Equal("package-first", "package-second"); + } + + [Fact] + public async Task ResolveAsync_AbsorbIssuesForSeveralTargets_SnapshotsEveryReportedTargetAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + ContentIntegrityTarget firstTarget = CreateTarget( + "package-first", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"), + ContentSourceKind.Manual); + ContentIntegrityTarget secondTarget = CreateTarget( + "package-second", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.3"), + ContentSourceKind.Manual); + LaunchContentIntegrityTargetContext firstContext = + new(firstTarget, CreateVersion(ContentSourceKind.Manual), false); + LaunchContentIntegrityTargetContext secondContext = + new(secondTarget, CreateVersion(ContentSourceKind.Manual, "1.3"), false); + ContentIntegrityReport report = CreateReport( + CreateIssue( + "package-first", + ContentSourceKind.Manual, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.Absorb), + CreateIssue( + "package-second", + ContentSourceKind.Manual, + IntegrityIssueKind.Untracked, + IntegrityIssueAction.Absorb)); + RecordingContentIntegrityService integrityService = CreateIntegrityService(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService(integrityService); + + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { firstContext, secondContext }), + null, + CancellationToken.None); + + integrityService.CapturedTargets.Should().Equal(firstTarget, secondTarget); + } + + [Fact] + public async Task ResolveAsync_RepairIssuesForSeveralPackages_RepairsEveryReportedPackageAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LaunchContentIntegrityTargetContext firstContext = new( + CreateTarget( + "package-first", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"), + ContentSourceKind.ManagedSingleFile), + CreateVersion(ContentSourceKind.ManagedSingleFile), + false); + LaunchContentIntegrityTargetContext secondContext = new( + CreateTarget( + "package-second", + Path.Combine(paths.ModsDirectory, "ShockWave", "1.3"), + ContentSourceKind.ManagedSingleFile), + CreateVersion(ContentSourceKind.ManagedSingleFile, "1.3"), + false); + ContentIntegrityReport report = CreateReport( + CreateIssue( + "package-first", + ContentSourceKind.ManagedSingleFile, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.Repair), + CreateIssue( + "package-second", + ContentSourceKind.ManagedSingleFile, + IntegrityIssueKind.ModifiedFile, + IntegrityIssueAction.Repair)); + var singleFilePackageUpdater = new RecordingSingleFilePackageUpdater(); + FileSystemLaunchContentIntegrityResolutionService service = CreateService( + singleFilePackageUpdater: singleFilePackageUpdater); + + await service.ResolveAsync( + new LaunchContentIntegrityResolutionRequest(paths, report, new[] { firstContext, secondContext }), + null, + CancellationToken.None); + + singleFilePackageUpdater.Requests.Should().HaveCount(2); + } + + private static FileSystemLaunchContentIntegrityResolutionService CreateService( + IContentIntegrityService? integrityService = null, + ILaunchContentIntegrityTargetBuilder? targetBuilder = null, + IS3ObjectManifestReader? manifestReader = null, + IS3PackageUpdater? s3PackageUpdater = null, + ISingleFilePackageUpdater? singleFilePackageUpdater = null, + IRemoteAssetDownloader? assetDownloader = null, + ILauncherContentCatalog? catalog = null, + IDownloadFileMetadataReader? metadataReader = null) + { + var packageSourceResolver = new ManagedPackageSourceResolver( + metadataReader ?? new StubDownloadFileMetadataReader("package.zip", null), + manifestReader ?? new RecordingS3ObjectManifestReader(), + NullLogger.Instance); + return new FileSystemLaunchContentIntegrityResolutionService( + integrityService ?? CreateIntegrityService(), + targetBuilder ?? new StubLaunchContentIntegrityTargetBuilder(), + packageSourceResolver, + s3PackageUpdater ?? new RecordingS3PackageUpdater(), + singleFilePackageUpdater ?? new RecordingSingleFilePackageUpdater(), + assetDownloader ?? new RecordingRemoteAssetDownloader(), + catalog ?? new FakeLauncherContentCatalog(), + NullLogger.Instance); + } + + private static RecordingContentIntegrityService CreateIntegrityService() + { + return new RecordingContentIntegrityService(); + } + + private static ContentIntegrityReport CreateReport( + string targetId, + ContentSourceKind sourceKind, + IntegrityIssueKind issueKind, + IntegrityIssueAction action, + string relativePath = "Data/file.big") + { + return CreateReport(CreateIssue(targetId, sourceKind, issueKind, action, relativePath)); + } + + private static ContentIntegrityReport CreateReport(params ContentIntegrityIssue[] issues) + { + return new ContentIntegrityReport(issues); + } + + private static ContentIntegrityIssue CreateIssue( + string targetId, + ContentSourceKind sourceKind, + IntegrityIssueKind issueKind, + IntegrityIssueAction action, + string relativePath = "Data/file.big") + { + return 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)); + } + + /// + /// Builds a version carrying the remote image link the shared content builder does not expose, because + /// is initialization-only. + /// + private static LauncherContentVersion CreateVersion( + ContentSourceKind sourceKind, + string version = "1.2", + string simpleDownloadLink = "https://www.dropbox.com/s/package/file.zip?dl=0", + string imageSourceLink = "https://cdn.example.test/card.jpg", + LauncherContentTheme? theme = null) + { + return new LauncherContentVersion + { + Installation = new LauncherContentInstallation { ContentSourceKind = sourceKind }, + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = version, + SimpleDownloadLink = simpleDownloadLink, + UIImageSourceLink = imageSourceLink, + Theme = theme + }; + } + + + private sealed class RecordingContentIntegrityService : IContentIntegrityService + { + public ContentIntegrityReport VerificationReport { get; set; } = + new(Array.Empty()); + + public bool CaptureIfMatchesExpectedFileSetResult { get; set; } + + public List> VerifiedTargetSets { get; } = []; + + public List VerifiedPaths { get; } = []; + + public List<( + ContentIntegrityTarget Target, + IReadOnlySet ExpectedRelativePaths)> ConditionalSnapshotRequests + { get; } = []; + + public List ConditionalSnapshotPaths { get; } = []; + + public List CapturedTargets { get; } = []; + + public List CapturedPaths { get; } = []; + + public List<( + ContentIntegrityReport Report, + IReadOnlyList Targets)> CleanupRequests + { get; } = []; + + /// + /// Names every observed call in order, so a test can pin that cleanup runs before a repair rewrites files. + /// + public List Calls { get; } = []; + + public Task VerifyAsync( + LauncherPaths paths, + IReadOnlyList targets, + CancellationToken cancellationToken) + { + Calls.Add("verify"); + VerifiedPaths.Add(paths); + VerifiedTargetSets.Add(targets); + return Task.FromResult(VerificationReport); + } + + public Task CaptureSnapshotIfMatchesExpectedFileSetAsync( + LauncherPaths paths, + ContentIntegrityTarget target, + IReadOnlySet expectedRelativePaths, + CancellationToken cancellationToken) + { + Calls.Add("conditional-capture"); + ConditionalSnapshotPaths.Add(paths); + ConditionalSnapshotRequests.Add((target, expectedRelativePaths)); + return Task.FromResult(CaptureIfMatchesExpectedFileSetResult); + } + + public Task CaptureSnapshotAsync( + LauncherPaths paths, + ContentIntegrityTarget target, + CancellationToken cancellationToken) + { + Calls.Add("capture"); + CapturedPaths.Add(paths); + CapturedTargets.Add(target); + return Task.CompletedTask; + } + + public Task ApplyCleanupAsync( + ContentIntegrityReport report, + IReadOnlyList targets, + CancellationToken cancellationToken) + { + Calls.Add("cleanup"); + CleanupRequests.Add((report, targets)); + return Task.CompletedTask; + } + } + + private sealed class StubLaunchContentIntegrityTargetBuilder : ILaunchContentIntegrityTargetBuilder + { + private readonly IReadOnlyList _contexts; + + public StubLaunchContentIntegrityTargetBuilder() + : this(Array.Empty()) + { + } + + public StubLaunchContentIntegrityTargetBuilder( + IReadOnlyList contexts) + { + _contexts = contexts; + } + + public List Requests { get; } = []; + + public IReadOnlyList BuildTargets( + LaunchContentIntegrityTargetRequest request) + { + Requests.Add(request); + return _contexts; + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilderTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilderTests.cs new file mode 100644 index 00000000..6b9b1528 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/FileSystemLaunchContentIntegrityTargetBuilderTests.cs @@ -0,0 +1,68 @@ +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; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class FileSystemLaunchContentIntegrityTargetBuilderTests +{ + [Fact] + public void Build_TargetsUsesLauncherOwnedTempPathsAndIgnoresInactiveCacheFiles() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + LauncherContentVersion activeVersion = CreateVersion("Rise", "1.0"); + LauncherContentVersion 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, LauncherContentTheme.ResolveBackgroundImageBaseName("0.9") + ".jpg"), "inactive background"); + var builder = new FileSystemLaunchContentIntegrityTargetBuilder(); + + IReadOnlyList targets = builder.BuildTargets( + new LaunchContentIntegrityTargetRequest( + paths, + new[] { activeVersion }, + new[] { activeVersion, inactiveVersion }, + "cache")); + + 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", LauncherContentTheme.ResolveBackgroundImageBaseName("0.9") + ".jpg"); + cacheTarget.Target.RootDirectory.Should().StartWith(paths.ImagesDirectory); + } + + [Fact] + public void BuildTargets_WhenTheImageCacheDirectoryIsMissing_IgnoresNothing() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + LauncherContentVersion activeVersion = CreateVersion("Rise", "1.0"); + LauncherContentVersion inactiveVersion = CreateVersion("Rise", "0.9"); + var builder = new FileSystemLaunchContentIntegrityTargetBuilder(); + + IReadOnlyList targets = builder.BuildTargets( + new LaunchContentIntegrityTargetRequest( + paths, + new[] { activeVersion }, + new[] { activeVersion, inactiveVersion }, + "cache")); + + targets.Single(target => target.IsCache).Target.IgnoredRelativePaths.Should().BeEmpty(); + } + + private static LauncherContentVersion CreateVersion(string name, string version) + { + return TestLauncherContent.Version(name, version, sourceKind: ContentSourceKind.Manual); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryServiceTests.cs new file mode 100644 index 00000000..3e0e1646 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameExecutableDiscoveryServiceTests.cs @@ -0,0 +1,223 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +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 WindowsGameExecutableDiscoveryServiceTests +{ + [Fact] + public void GetGameClients_ReturnsGeneralsOnlineThenCommunityThenRetailWithAvailability() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(Path.Combine(directory.Path, "Game")); + CreateGameFile(paths, "generalszh.exe"); + CreateGameFile(paths, "generalsonlinezh.exe"); + CreateGameFile(paths, "generals.exe"); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + IReadOnlyList clients = service.GetGameClients(); + + clients.Should().HaveCount(3); + clients[0].ExecutableName.Should().Be("generalsonlinezh.exe"); + clients[0].Kind.Should().Be(BuiltInExecutableKind.GeneralsOnline); + clients[0].IsAvailable.Should().BeTrue(); + clients[1].ExecutableName.Should().Be("generalszh.exe"); + clients[1].Kind.Should().Be(BuiltInExecutableKind.Community); + clients[1].IsAvailable.Should().BeTrue(); + clients[2].ExecutableName.Should().Be("generals.exe"); + clients[2].Kind.Should().Be(BuiltInExecutableKind.Retail); + clients[2].IsAvailable.Should().BeTrue(); + } + + [Fact] + public void GetGameClients_KeepsMissingBuiltInsVisible() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(Path.Combine(directory.Path, "Game")); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + IReadOnlyList clients = service.GetGameClients(); + + clients.Select(client => client.ExecutableName).Should() + .Equal("generalsonlinezh.exe", "generalszh.exe", "generals.exe"); + clients.Should().OnlyContain(client => !client.IsAvailable); + } + + [Fact] + public void GetGameClients_UsesManagedGeneralsCommunityThenRetailExecutables() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create( + Path.Combine(directory.Path, "Game"), + SupportedGame.Generals); + CreateGameFile(paths, "generalsv.exe"); + CreateGameFile(paths, "generals.exe"); + CreateGameFile(paths, "generalsonlinezh.exe"); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + IReadOnlyList clients = service.GetGameClients(); + + clients.Should().HaveCount(2); + clients[0].ExecutableName.Should().Be("generalsv.exe"); + clients[0].Kind.Should().Be(BuiltInExecutableKind.Community); + clients[1].ExecutableName.Should().Be("generals.exe"); + clients[1].Kind.Should().Be(BuiltInExecutableKind.Retail); + } + + [Fact] + public void GetWorldBuilders_ReturnsRetailThenCommunityWhenPresent() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(Path.Combine(directory.Path, "Game")); + CreateGameFile(paths, "WorldBuilder.exe"); + CreateGameFile(paths, "worldbuilderzh.exe"); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + IReadOnlyList worldBuilders = + service.GetWorldBuilders(); + + worldBuilders.Should().HaveCount(2); + worldBuilders[0].ExecutableName.Should().Be("WorldBuilder.exe"); + worldBuilders[0].Kind.Should().Be(BuiltInExecutableKind.Retail); + worldBuilders[0].IsAvailable.Should().BeTrue(); + worldBuilders[1].ExecutableName.Should().Be("worldbuilderzh.exe"); + worldBuilders[1].Kind.Should().Be(BuiltInExecutableKind.Community); + worldBuilders[1].IsAvailable.Should().BeTrue(); + } + + [Fact] + public void IsExecutableAvailable_ChecksRelativeNamesInGameDirectory() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(Path.Combine(directory.Path, "Game")); + CreateGameFile(paths, "generalszh.exe"); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + bool available = service.IsExecutableAvailable("generalszh.exe"); + bool missing = service.IsExecutableAvailable("missing.exe"); + + available.Should().BeTrue(); + missing.Should().BeFalse(); + } + + [Fact] + public void IsExecutableAvailable_RejectsRootedExecutablePaths() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(Path.Combine(directory.Path, "Game")); + string executablePath = Path.Combine(directory.Path, "external.exe"); + File.WriteAllText(executablePath, string.Empty); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + bool available = service.IsExecutableAvailable(executablePath); + + available.Should().BeFalse(); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void IsExecutableAvailable_WithoutAnExecutableName_ReturnsFalse(string? executableName) + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(Path.Combine(directory.Path, "Game")); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + bool available = service.IsExecutableAvailable(executableName); + + available.Should().BeFalse(); + } + + [Fact] + public void IsExecutableAvailable_WhenADirectoryCarriesTheExecutableName_ReturnsFalse() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(Path.Combine(directory.Path, "Game")); + Directory.CreateDirectory(Path.Combine(paths.GameDirectory, "generalszh.exe")); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + bool available = service.IsExecutableAvailable("generalszh.exe"); + + available.Should().BeFalse(); + } + + [Fact] + public void IsExecutableAvailable_AcceptsRootLevelHardLink() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(Path.Combine(directory.Path, "Game")); + Directory.CreateDirectory(paths.GameDirectory); + string sourcePath = directory.CreateFile("source.exe", string.Empty); + string hardLinkPath = Path.Combine(paths.GameDirectory, "custom.exe"); + new WindowsHardLinkCreator().TryCreateHardLink(hardLinkPath, sourcePath).Should().BeTrue(); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + service.IsExecutableAvailable("custom.exe").Should().BeTrue(); + } + + [SymbolicLinkFact] + public void IsExecutableAvailable_RejectsRootLevelSymbolicLink() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(Path.Combine(directory.Path, "Game")); + Directory.CreateDirectory(paths.GameDirectory); + string targetPath = directory.CreateFile("target.exe", string.Empty); + SymbolicLinkTestSupport.CreateFileLink( + Path.Combine(paths.GameDirectory, "custom.exe"), + targetPath); + WindowsGameExecutableDiscoveryService service = CreateService(paths); + + service.IsExecutableAvailable("custom.exe").Should().BeFalse(); + } + + [Fact] + public void Discovery_UsesNewActiveInstallationWithoutRebuildingService() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + var storagePaths = new LauncherStoragePaths(executableDirectory); + LauncherPaths generalsPaths = storagePaths.CreateGamePaths( + SupportedGame.Generals, + directory.CreateDirectory("GeneralsGame")); + LauncherPaths zeroHourPaths = storagePaths.CreateGamePaths( + SupportedGame.ZeroHour, + directory.CreateDirectory("ZeroHourGame")); + CreateGameFile(generalsPaths, "generalsv.exe"); + CreateGameFile(zeroHourPaths, "generalszh.exe"); + var runtimePaths = new LauncherRuntimePathContext(storagePaths, generalsPaths); + var service = new WindowsGameExecutableDiscoveryService( + runtimePaths, + NullLogger.Instance); + + service.GetGameClients()[0] + .Should().Match(client => + client.ExecutableName == "generalsv.exe" && client.IsAvailable); + + runtimePaths.SwitchActive(zeroHourPaths); + + service.GetGameClients()[1] + .Should().Match(client => + client.ExecutableName == "generalszh.exe" && client.IsAvailable); + } + + private static WindowsGameExecutableDiscoveryService CreateService(LauncherPaths paths) + { + return new WindowsGameExecutableDiscoveryService( + TestLauncherPaths.CreateRuntimePathContext(paths), + NullLogger.Instance); + } + + private static void CreateGameFile(LauncherPaths paths, string fileName) + { + Directory.CreateDirectory(paths.GameDirectory); + File.WriteAllText(Path.Combine(paths.GameDirectory, fileName), string.Empty); + } + +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameProcessLauncherTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameProcessLauncherTests.cs new file mode 100644 index 00000000..4722d1af --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsGameProcessLauncherTests.cs @@ -0,0 +1,272 @@ +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.Infrastructure.Launching.Services; +using GenLauncherGO.Infrastructure.Launching.Support; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class WindowsGameProcessLauncherTests +{ + [Fact] + public void GameLaunchRequest_RejectsExternalOrNestedExecutablePaths() + { + using var directory = new TestDirectory(); + + Action rooted = () => GameLaunchRequest.ForGameClient( + directory.Path, + Path.Combine(directory.Path, "external.exe"), + string.Empty); + Action nested = () => GameLaunchRequest.ForWorldBuilder( + directory.Path, + @"tools\custom.exe", + string.Empty); + Action traversal = () => GameLaunchRequest.ForGameClient( + directory.Path, + @"..\custom.exe", + string.Empty); + + rooted.Should().Throw(); + nested.Should().Throw(); + traversal.Should().Throw(); + } + + [Fact] + public async Task StartAsync_UsesExplicitGameExecutableAndArgumentsAsync() + { + using var directory = new TestDirectory(); + string executableName = directory.CreateFile("custom.exe", string.Empty); + var processLauncher = new RecordingProcessFamilyLauncher + { + RunningDuration = TimeSpan.FromSeconds(13) + }; + WindowsGameProcessLauncher launcher = CreateLauncher(processLauncher); + + bool succeeded = await StartAndCompleteAsync( + launcher, + GameLaunchRequest.ForGameClient(directory.Path, "custom.exe", "-quickstart"), + CancellationToken.None); + + succeeded.Should().BeTrue(); + processLauncher.Calls.Should().ContainSingle().Which.Should().Be( + (executableName, "-quickstart", directory.Path)); + } + + [Theory] + [InlineData(11_999, false)] + [InlineData(12_000, true)] + public async Task StartAsync_GameClientSuccessRequiresTwelveSecondDurationAsync( + int runningDurationMilliseconds, + bool expectedSuccess) + { + using var directory = new TestDirectory(); + directory.CreateFile("custom.exe", string.Empty); + var processLauncher = new RecordingProcessFamilyLauncher + { + RunningDuration = TimeSpan.FromMilliseconds(runningDurationMilliseconds) + }; + WindowsGameProcessLauncher launcher = CreateLauncher(processLauncher); + + bool succeeded = await StartAndCompleteAsync( + launcher, + GameLaunchRequest.ForGameClient(directory.Path, "custom.exe", string.Empty), + CancellationToken.None); + + succeeded.Should().Be(expectedSuccess); + } + + [Fact] + public async Task StartAsync_ShortGameLaunchWarningDoesNotExposeGameDirectoryAsync() + { + using var directory = new TestDirectory(); + directory.CreateFile("custom.exe", string.Empty); + var processLauncher = new RecordingProcessFamilyLauncher + { + RunningDuration = TimeSpan.FromSeconds(1) + }; + RecordingLogger logger = new(); + WindowsGameProcessLauncher launcher = new(processLauncher, logger); + + bool succeeded = await StartAndCompleteAsync( + launcher, + GameLaunchRequest.ForGameClient(directory.Path, "custom.exe", string.Empty), + CancellationToken.None); + + succeeded.Should().BeFalse(); + logger.Entries.Should().ContainSingle(entry => + entry.LogLevel == LogLevel.Warning && + entry.Message.Contains("custom.exe", StringComparison.Ordinal) && + !entry.Message.Contains(directory.Path, StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task StartAsync_UsesExplicitWorldBuilderExecutableAndArgumentsAsync() + { + using var directory = new TestDirectory(); + string executableName = directory.CreateFile("custom-wb.exe", string.Empty); + var processLauncher = new RecordingProcessFamilyLauncher(); + WindowsGameProcessLauncher launcher = CreateLauncher(processLauncher); + + bool succeeded = await StartAndCompleteAsync( + launcher, + GameLaunchRequest.ForWorldBuilder(directory.Path, "custom-wb.exe", "-wb"), + CancellationToken.None); + + succeeded.Should().BeTrue(); + processLauncher.Calls.Should().ContainSingle().Which.Should().Be( + (executableName, "-wb", directory.Path)); + } + + [Fact] + public async Task ForceClose_StopsTheLaunchedProcessFamilyAsync() + { + using var directory = new TestDirectory(); + directory.CreateFile("custom.exe", string.Empty); + var processLauncher = new RecordingProcessFamilyLauncher { RunningDuration = null }; + WindowsGameProcessLauncher launcher = CreateLauncher(processLauncher); + IGameProcessLaunchOperation operation = await launcher.StartAsync( + GameLaunchRequest.ForGameClient(directory.Path, "custom.exe", string.Empty), + CancellationToken.None); + + operation.ForceClose(); + + processLauncher.StartedOperation!.ForceCloseCount.Should().Be(1); + } + + [Fact] + public async Task StartAsync_ForwardsCurrentExecutableNameChangesUntilTheGameExitsAsync() + { + using var directory = new TestDirectory(); + directory.CreateFile("custom.exe", string.Empty); + var processLauncher = new RecordingProcessFamilyLauncher { RunningDuration = null }; + WindowsGameProcessLauncher launcher = CreateLauncher(processLauncher); + List observedExecutableNames = []; + + IGameProcessLaunchOperation operation = await launcher.StartAsync( + GameLaunchRequest.ForGameClient(directory.Path, "custom.exe", string.Empty), + CancellationToken.None); + operation.CurrentExecutableNameChanged += (_, _) => + observedExecutableNames.Add(operation.CurrentExecutableName); + processLauncher.StartedOperation!.RaiseCurrentExecutableNameChanged("game.dat"); + processLauncher.StartedOperation.Complete(TimeSpan.FromSeconds(13)); + await operation.Completion; + processLauncher.StartedOperation.RaiseCurrentExecutableNameChanged("worldbuilder.exe"); + + observedExecutableNames.Should().Equal("game.dat"); + } + + [Fact] + public async Task StartAsync_PropagatesProcessFamilyFailureAsync() + { + using var directory = new TestDirectory(); + directory.CreateFile("custom.exe", string.Empty); + var failure = new InvalidOperationException("the launched process could not be observed"); + var processLauncher = new RecordingProcessFamilyLauncher { Failure = failure }; + WindowsGameProcessLauncher launcher = CreateLauncher(processLauncher); + + IGameProcessLaunchOperation operation = await launcher.StartAsync( + GameLaunchRequest.ForGameClient(directory.Path, "custom.exe", string.Empty), + CancellationToken.None); + Func> complete = () => operation.Completion; + + (await complete.Should().ThrowAsync()).Which.Should().BeSameAs(failure); + } + + [Fact] + public async Task StartAsync_RejectsMissingExecutableAsync() + { + using var directory = new TestDirectory(); + WindowsGameProcessLauncher launcher = CreateLauncher(new RecordingProcessFamilyLauncher()); + var request = GameLaunchRequest.ForGameClient( + directory.Path, + "missing.exe", + string.Empty); + + Func start = () => launcher.StartAsync(request, CancellationToken.None); + + await start.Should().ThrowAsync(); + } + + // Needs a file that is itself a reparse point, which only a symbolic link provides. A junction + // is a directory, so File.Exists short-circuits and production never reaches the reparse check. + [SymbolicLinkFact] + public async Task StartAsync_RejectsExecutableSymbolicLinkAsync() + { + using var directory = new TestDirectory(); + string targetPath = directory.CreateFile("target.bin", string.Empty); + SymbolicLinkTestSupport.CreateFileLink( + Path.Combine(directory.Path, "custom.exe"), + targetPath); + WindowsGameProcessLauncher launcher = CreateLauncher(new RecordingProcessFamilyLauncher()); + var request = GameLaunchRequest.ForGameClient( + directory.Path, + "custom.exe", + string.Empty); + + Func start = () => launcher.StartAsync(request, CancellationToken.None); + + await start.Should().ThrowAsync() + .WithMessage("*reparse point*"); + } + + private static WindowsGameProcessLauncher CreateLauncher(RecordingProcessFamilyLauncher processLauncher) + { + return new WindowsGameProcessLauncher( + processLauncher, + NullLogger.Instance); + } + + private static async Task StartAndCompleteAsync( + WindowsGameProcessLauncher launcher, + GameLaunchRequest request, + CancellationToken cancellationToken) + { + IGameProcessLaunchOperation operation = await launcher.StartAsync(request, cancellationToken); + return await operation.Completion; + } + + private sealed class RecordingProcessFamilyLauncher : IProcessFamilyLauncher + { + /// + /// Completes the launched operation immediately with this duration. Null leaves the operation running so a + /// test can drive itself. + /// + public TimeSpan? RunningDuration { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// Fails the launched operation instead of completing it. + /// + public Exception? Failure { get; set; } + + public ControllableProcessFamilyLaunchOperation? StartedOperation { get; private set; } + + public List<(string ExecutableName, string Arguments, string WorkingDirectory)> Calls { get; } = []; + + public Task StartAsync( + string executableName, + string arguments, + string workingDirectory, + CancellationToken cancellationToken) + { + Calls.Add((executableName, arguments, workingDirectory)); + ControllableProcessFamilyLaunchOperation operation = new(executableName); + StartedOperation = operation; + if (Failure is not null) + { + operation.Completion = Task.FromException(Failure); + } + else if (RunningDuration is not null) + { + operation.Complete(RunningDuration.Value); + } + + return Task.FromResult(operation); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsProcessFamilyLauncherTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsProcessFamilyLauncherTests.cs new file mode 100644 index 00000000..26ec37b2 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Services/WindowsProcessFamilyLauncherTests.cs @@ -0,0 +1,655 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Launching.Services; +using GenLauncherGO.Infrastructure.Launching.Support; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Services; + +public sealed class WindowsProcessFamilyLauncherTests +{ + /// + /// A descendant that outlives every wait in these tests, so a launch only ends because the launcher stopped it. + /// + private const string LongRunningChildArguments = "/d /c ping.exe -n 60 127.0.0.1 >nul"; + + private const string ChildExecutableFileName = "ping.exe"; + + [Fact] + public async Task StartAsync_UsesArgumentsAndWorkingDirectoryBeforeCompletingAsync() + { + using TestDirectory directory = new(); + WindowsProcessFamilyLauncher launcher = new(NullLogger.Instance); + string executableName = Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe"; + const string MarkerFileName = "launcher-marker.txt"; + + IProcessFamilyLaunchOperation operation = await launcher.StartAsync( + executableName, + $"/d /c echo launched>{MarkerFileName}", + directory.Path, + CancellationToken.None); + await operation.Completion.WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + + File.ReadAllText(directory.GetPath(MarkerFileName)).Trim().Should().Be("launched"); + } + + [Fact] + public void ProcessFamilyTracker_StopsImmediatelyWhenStableNestedDescendantExits() + { + ManualTimeProvider clock = new(); + Queue?> snapshots = new(new[] + { + Snapshot((10, 1), (20, 10), (30, 20)), + Snapshot((30, 20)), + Snapshot() + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + snapshots.Dequeue, + timeProvider: clock); + + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromSeconds(1)); + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromSeconds(1)); + tracker.IsRunning().Should().BeFalse(); + tracker.RunningDuration.Should().Be(TimeSpan.FromSeconds(1)); + } + + [Fact] + public void ProcessFamilyTracker_AllowsHandoffChildFromRecentlyRetiredParent() + { + ManualTimeProvider clock = new(); + Queue?> snapshots = new(new[] + { + Snapshot((10, 1), (20, 10)), + Snapshot(), + Snapshot((30, 20)) + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + snapshots.Dequeue, + timeProvider: clock); + + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromMilliseconds(250)); + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromMilliseconds(250)); + tracker.IsRunning().Should().BeTrue(); + } + + [Fact] + public void ProcessFamilyTracker_RejectsHandoffChildAfterParentRetirementExpires() + { + ManualTimeProvider clock = new(); + Queue?> snapshots = new(new[] + { + Snapshot((10, 1), (20, 10)), + Snapshot(), + Snapshot((30, 20)) + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + snapshots.Dequeue, + timeProvider: clock); + + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromSeconds(1)); + tracker.IsRunning().Should().BeFalse(); + } + + [Fact] + public void ProcessFamilyTracker_StopsImmediatelyWhenRootExitsWithoutChildren() + { + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + () => Snapshot()); + + bool result = tracker.IsRunning(); + + result.Should().BeFalse(); + } + + [Fact] + public void ProcessFamilyTracker_FallsBackToRootProcessWhenSnapshotsFail() + { + ManualTimeProvider clock = new(); + Queue rootRunningStates = new(new[] { true, false }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + () => null, + isProcessRunning: _ => rootRunningStates.Dequeue(), + timeProvider: clock); + + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromSeconds(3)); + tracker.IsRunning().Should().BeFalse(); + tracker.RunningDuration.Should().Be(TimeSpan.Zero); + } + + [Fact] + public void ProcessFamilyTrackerForceClose_TargetsTrackedRunningFamily() + { + List forceClosedProcessIds = []; + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + () => Snapshot((10, 1), (20, 10), (30, 20), (40, 99)), + forceCloseProcess: forceClosedProcessIds.Add); + tracker.IsRunning().Should().BeTrue(); + + tracker.ForceClose(); + + forceClosedProcessIds.Should().BeEquivalentTo(new[] { 10, 20, 30 }); + } + + [Fact] + public void ProcessFamilyTrackerForceClose_SkipsDescendantThatAlreadyExited() + { + List forceClosedProcessIds = []; + Queue?> snapshots = new(new[] + { + Snapshot((10, 1), (20, 10), (30, 20), (40, 99)), + Snapshot((10, 1), (30, 20), (40, 99)), + Snapshot((10, 1), (30, 20), (40, 99)) + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + snapshots.Dequeue, + forceCloseProcess: forceClosedProcessIds.Add); + tracker.IsRunning().Should().BeTrue(); + tracker.IsRunning().Should().BeTrue(); + + tracker.ForceClose(); + + forceClosedProcessIds.Should().BeEquivalentTo(new[] { 10, 30 }); + } + + [Fact] + public void ProcessFamilyTrackerForceClose_WhenSnapshotsFail_TargetsOnlyStillRunningTrackedProcesses() + { + List forceClosedProcessIds = []; + Queue?> snapshots = new(new[] + { + Snapshot((10, 1), (20, 10)), + null + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + snapshots.Dequeue, + isProcessRunning: processId => processId == 10, + forceCloseProcess: forceClosedProcessIds.Add); + tracker.IsRunning().Should().BeTrue(); + + tracker.ForceClose(); + + forceClosedProcessIds.Should().BeEquivalentTo(new[] { 10 }); + } + + [Fact] + public void ProcessFamilyTracker_UpdatesCurrentExecutableToDeepestRunningDescendant() + { + 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( + 10, + rootExecutableName: "generalsonlinezh.exe", + captureProcessSnapshot: snapshots.Dequeue); + + 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 ProcessFamilyTracker_RootExecutableNameDoesNotExposeDirectory() + { + string executablePath = Path.Combine("C:", "Games", "Zero Hour", "generalszh.exe"); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + captureProcessSnapshot: Array.Empty, + rootExecutableName: executablePath); + + tracker.CurrentExecutableName.Should().Be("generalszh.exe"); + } + + [Fact] + public void ProcessFamilyTracker_StopsAfterChildHandoffExitsEvenWhenRootLauncherStillRuns() + { + ManualTimeProvider clock = new(); + 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( + 10, + rootExecutableName: "generalsonlinezh.exe", + captureProcessSnapshot: snapshots.Dequeue, + timeProvider: clock); + + tracker.IsRunning().Should().BeTrue(); + tracker.CurrentExecutableName.Should().Be("generalszh.exe"); + clock.Advance(TimeSpan.FromSeconds(1)); + tracker.IsRunning().Should().BeFalse(); + } + + [Fact] + public async Task StartAsync_ReportsHowLongTheProcessFamilyRanAsync() + { + using TestDirectory directory = new(); + WindowsProcessFamilyLauncher launcher = new(NullLogger.Instance); + + IProcessFamilyLaunchOperation operation = await launcher.StartAsync( + CommandProcessorFileName, + "/d /c ping.exe -n 4 127.0.0.1 >nul", + directory.Path, + CancellationToken.None); + TimeSpan runningDuration = await operation.Completion.WaitAsync(TimeSpan.FromSeconds(25), TestContext.Current.CancellationToken); + + runningDuration.Should().BeGreaterThan(TimeSpan.FromSeconds(1)); + } + + [Fact] + public async Task StartAsync_ReportsTheDescendantExecutableWhileTheFamilyRunsAsync() + { + using TestDirectory directory = new(); + WindowsProcessFamilyLauncher launcher = new(NullLogger.Instance); + IProcessFamilyLaunchOperation operation = await launcher.StartAsync( + CommandProcessorFileName, + LongRunningChildArguments, + directory.Path, + CancellationToken.None); + + try + { + List reportedExecutableNames = []; + operation.CurrentExecutableNameChanged += (_, _) => + reportedExecutableNames.Add(operation.CurrentExecutableName); + + bool reportedTheChild = await WaitUntilAsync( + () => reportedExecutableNames.Exists(IsChildExecutableName), + TimeSpan.FromSeconds(12)); + + reportedTheChild.Should().BeTrue(); + } + finally + { + operation.ForceClose(); + await operation.Completion.WaitAsync(TimeSpan.FromSeconds(20), TestContext.Current.CancellationToken); + } + } + + [Fact] + public async Task StartAsync_DoesNotReportExecutableNameChangesWhileTheSameProcessRunsAsync() + { + using TestDirectory directory = new(); + WindowsProcessFamilyLauncher launcher = new(NullLogger.Instance); + int reportedNameChangeCount = 0; + + IProcessFamilyLaunchOperation operation = await launcher.StartAsync( + ChildExecutableFileName, + "-n 4 127.0.0.1", + directory.Path, + CancellationToken.None); + operation.CurrentExecutableNameChanged += (_, _) => Interlocked.Increment(ref reportedNameChangeCount); + await operation.Completion.WaitAsync(TimeSpan.FromSeconds(25), TestContext.Current.CancellationToken); + + reportedNameChangeCount.Should().Be(0); + } + + [Fact] + public async Task StartAsync_WhenTheLaunchIsCanceled_ReportsCancellationAsync() + { + using CancellationTokenSource cancellation = new(); + WindowsProcessFamilyLauncher launcher = new(NullLogger.Instance); + IProcessFamilyLaunchOperation operation = await launcher.StartAsync( + CommandProcessorFileName, + LongRunningChildArguments, + Path.GetTempPath(), + cancellation.Token); + + try + { + await cancellation.CancelAsync(); + + Func completion = () => operation.Completion.WaitAsync(TimeSpan.FromSeconds(15)); + await completion.Should().ThrowAsync(); + } + finally + { + operation.ForceClose(); + } + } + + [Fact] + public async Task ForceClose_StopsTheLaunchedProcessFamilyAsync() + { + using TestDirectory directory = new(); + WindowsProcessFamilyLauncher launcher = new(NullLogger.Instance); + IProcessFamilyLaunchOperation operation = await launcher.StartAsync( + CommandProcessorFileName, + LongRunningChildArguments, + directory.Path, + CancellationToken.None); + await WaitUntilAsync( + () => IsChildExecutableName(operation.CurrentExecutableName), + TimeSpan.FromSeconds(12)); + + operation.ForceClose(); + + Func familyExit = () => operation.Completion.WaitAsync(TimeSpan.FromSeconds(20)); + await familyExit.Should().NotThrowAsync(); + } + + [Fact] + public void ProcessFamilyTracker_ReportsMostRecentlyDiscoveredSiblingAsCurrentExecutable() + { + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + () => NamedSnapshot( + (10, 1, "generalsonlinezh.exe"), + (20, 10, "handoff-helper.exe"), + (30, 10, "generalszh.exe")), + rootExecutableName: "generalsonlinezh.exe"); + tracker.IsRunning().Should().BeTrue(); + + string currentExecutableName = tracker.CurrentExecutableName; + + currentExecutableName.Should().Be("generalszh.exe"); + } + + [Fact] + public void ProcessFamilyTracker_ReportsDeepestDescendantRatherThanNewestSiblingAsCurrentExecutable() + { + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + () => NamedSnapshot( + (10, 1, "generalsonlinezh.exe"), + (20, 10, "generalszh.exe"), + (30, 20, "game.dat"), + (40, 10, "handoff-helper.exe")), + rootExecutableName: "generalsonlinezh.exe"); + tracker.IsRunning().Should().BeTrue(); + + string currentExecutableName = tracker.CurrentExecutableName; + + currentExecutableName.Should().Be("game.dat"); + } + + [Fact] + public void ProcessFamilyTracker_KeepsTheDescendantNameWhileOnlyTheLauncherRootRemains() + { + Queue?> snapshots = new(new[] + { + NamedSnapshot((10, 1, "generalsonlinezh.exe"), (20, 10, "generalszh.exe")), + NamedSnapshot((10, 1, "generalsonlinezh.exe")) + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + snapshots.Dequeue, + rootExecutableName: "generalsonlinezh.exe"); + tracker.IsRunning().Should().BeTrue(); + tracker.IsRunning().Should().BeTrue(); + + string currentExecutableName = tracker.CurrentExecutableName; + + currentExecutableName.Should().Be("generalszh.exe"); + } + + /// + /// A process snapshot is not ordered parent-first, so a grandchild listed before its parent must still be + /// tracked instead of escaping the cleanup wait. + /// + [Fact] + public void ProcessFamilyTracker_TracksADescendantListedBeforeItsParent() + { + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + () => NamedSnapshot( + (30, 20, "game.dat"), + (20, 10, "generalszh.exe"), + (10, 1, "generalsonlinezh.exe")), + rootExecutableName: "generalsonlinezh.exe"); + tracker.IsRunning().Should().BeTrue(); + + string currentExecutableName = tracker.CurrentExecutableName; + + currentExecutableName.Should().Be("game.dat"); + } + + [Fact] + public void ProcessFamilyTracker_KeepsTheCurrentExecutableWhenADescendantHasNoName() + { + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + () => NamedSnapshot((10, 1, "generalszh.exe"), (20, 10, "")), + rootExecutableName: "generalszh.exe"); + tracker.IsRunning().Should().BeTrue(); + + string currentExecutableName = tracker.CurrentExecutableName; + + currentExecutableName.Should().Be("generalszh.exe"); + } + + [Fact] + public void ProcessFamilyTracker_WhenSnapshotsStopWorking_ReportsTheRootExecutableName() + { + Queue?> snapshots = new(new[] + { + NamedSnapshot((10, 1, "generalsonlinezh.exe"), (20, 10, "generalszh.exe")), + null + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + snapshots.Dequeue, + rootExecutableName: "generalsonlinezh.exe", + isProcessRunning: processId => processId == 10); + tracker.IsRunning().Should().BeTrue(); + tracker.CurrentExecutableName.Should().Be("generalszh.exe"); + + tracker.IsRunning().Should().BeTrue(); + + tracker.CurrentExecutableName.Should().Be("generalsonlinezh.exe"); + } + + [Fact] + public void ProcessFamilyTracker_MeasuresTheHandoffWindowFromTheDeepestRetiredDescendant() + { + ManualTimeProvider clock = new(); + Queue?> snapshots = new(new[] + { + Snapshot((10, 1), (20, 10)), + Snapshot((10, 1), (20, 10), (30, 20)), + Snapshot() + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + snapshots.Dequeue, + timeProvider: clock); + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromMilliseconds(400)); + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromMilliseconds(200)); + + bool running = tracker.IsRunning(); + + running.Should().BeTrue(); + } + + [Fact] + public void ProcessFamilyTracker_MeasuresTheHandoffWindowFromTheNewestRetiredSibling() + { + ManualTimeProvider clock = new(); + Queue?> snapshots = new(new[] + { + Snapshot((10, 1), (20, 10)), + Snapshot((10, 1), (20, 10), (30, 10)), + Snapshot() + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + snapshots.Dequeue, + timeProvider: clock); + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromMilliseconds(400)); + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromMilliseconds(200)); + + bool running = tracker.IsRunning(); + + running.Should().BeTrue(); + } + + [Fact] + public void ProcessFamilyTracker_ClosesTheHandoffWindowExactlyOneGracePeriodAfterTheChildWasSeen() + { + ManualTimeProvider clock = new(); + Queue?> snapshots = new(new[] + { + Snapshot((10, 1), (20, 10)), + Snapshot(), + Snapshot() + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + snapshots.Dequeue, + timeProvider: clock); + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromMilliseconds(300)); + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromMilliseconds(200)); + + bool running = tracker.IsRunning(); + + running.Should().BeFalse(); + } + + [Fact] + public void ProcessFamilyTracker_ForgetsARetiredParentExactlyOneGracePeriodAfterItExited() + { + ManualTimeProvider clock = new(); + Queue?> snapshots = new(new[] + { + Snapshot((10, 1), (20, 10)), + Snapshot(), + Snapshot((30, 20)) + }); + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + snapshots.Dequeue, + timeProvider: clock); + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromMilliseconds(100)); + tracker.IsRunning().Should().BeTrue(); + clock.Advance(TimeSpan.FromMilliseconds(500)); + + bool running = tracker.IsRunning(); + + running.Should().BeFalse(); + } + + [Fact] + public void ProcessFamilyTrackerForceClose_DiscoversDescendantsWithoutAPriorRunningCheck() + { + List forceClosedProcessIds = []; + WindowsProcessFamilyLauncher.ProcessFamilyTracker tracker = CreateTracker( + 10, + () => Snapshot((10, 1), (20, 10), (30, 20), (40, 99)), + forceCloseProcess: forceClosedProcessIds.Add); + + tracker.ForceClose(); + + forceClosedProcessIds.Should().BeEquivalentTo(new[] { 10, 20, 30 }); + } + + private static string CommandProcessorFileName => + Environment.GetEnvironmentVariable("ComSpec") ?? "cmd.exe"; + + private static bool IsChildExecutableName(string executableName) + { + return executableName.StartsWith("ping", StringComparison.OrdinalIgnoreCase); + } + + /// + /// Polls until holds, so a test can observe the launcher's own background poll + /// loop without depending on how fast Windows starts the descendant process. + /// + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + DateTime deadlineUtc = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadlineUtc) + { + if (condition()) + { + return true; + } + + await Task.Delay(TimeSpan.FromMilliseconds(25)); + } + + return condition(); + } + + private static WindowsProcessFamilyLauncher.ProcessFamilyTracker CreateTracker( + int rootProcessId, + Func?> captureProcessSnapshot, + string rootExecutableName = "", + Func? isProcessRunning = null, + ManualTimeProvider? timeProvider = null, + Action? forceCloseProcess = null) + { + return new WindowsProcessFamilyLauncher.ProcessFamilyTracker( + rootProcessId, + rootExecutableName, + NullLogger.Instance, + captureProcessSnapshot, + isProcessRunning ?? (_ => false), + timeProvider ?? new ManualTimeProvider(), + TimeSpan.FromMilliseconds(500), + forceCloseProcess ?? (_ => { })); + } + + private static IReadOnlyList Snapshot( + params (int ProcessId, int ParentProcessId)[] entries) + { + List snapshot = []; + 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 = []; + 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/DeploymentFilePlannerTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Support/DeploymentFilePlannerTests.cs new file mode 100644 index 00000000..5b580bc5 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Support/DeploymentFilePlannerTests.cs @@ -0,0 +1,107 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using GenLauncherGO.Infrastructure.Launching.Support; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Support; + +public sealed class DeploymentFilePlannerTests +{ + [Fact] + public void ResolveDeploymentFiles_ExcludesExecutableCodeFromDownloadedPackages() + { + using TestDirectory directory = new(); + string packageRoot = directory.CreateDirectory("Package"); + string dataDirectory = Directory.CreateDirectory(Path.Combine(packageRoot, "Data")).FullName; + File.WriteAllText(Path.Combine(dataDirectory, "b.ini"), "second"); + File.WriteAllText(Path.Combine(dataDirectory, "a.ini"), "first"); + File.WriteAllText(Path.Combine(packageRoot, "Zeta.big"), "archive"); + File.WriteAllText(Path.Combine(packageRoot, "community-client.EXE"), "executable"); + File.WriteAllText(Path.Combine(packageRoot, "community-plugin.DlL"), "library"); + + IReadOnlyList result = DeploymentFilePlanner.ResolveDeploymentFiles( + new[] { new DeploymentPackage(packageRoot, 0) }); + + result.Select(file => file.TargetRelativePath) + .Should() + .Equal("Data/a.ini", "Data/b.ini", "Zeta.big"); + } + + [Fact] + public void ResolveDeploymentFiles_HigherPrecedencePackageReplacesSameTarget() + { + using TestDirectory directory = new(); + string modRoot = directory.CreateDirectory("Mod"); + string addonRoot = directory.CreateDirectory("Addon"); + Directory.CreateDirectory(Path.Combine(modRoot, "Data")); + Directory.CreateDirectory(Path.Combine(addonRoot, "Data")); + File.WriteAllText(Path.Combine(modRoot, "Data", "file.ini"), "mod"); + string addonSourcePath = Path.Combine(addonRoot, "Data", "file.ini"); + File.WriteAllText(addonSourcePath, "addon"); + + IReadOnlyList result = DeploymentFilePlanner.ResolveDeploymentFiles( + new[] + { + new DeploymentPackage(addonRoot, 1), + new DeploymentPackage(modRoot, 0) + }); + + result.Should().ContainSingle().Which.Should().Match(file => + file.TargetRelativePath == "Data/file.ini" && + file.SourcePath == addonSourcePath); + } + + [Fact] + public void ResolveDeploymentFiles_WhenAPackageDirectoryIsMissing_ReportsTheMissingDirectory() + { + using TestDirectory directory = new(); + string packageRoot = Path.Combine(directory.Path, "Missing"); + + Action resolve = () => DeploymentFilePlanner.ResolveDeploymentFiles( + new[] { new DeploymentPackage(packageRoot, 0) }); + + resolve.Should().Throw(); + } + + [Fact] + public void ResolveDeploymentFiles_WhenThePackageRootIsALink_RefusesToReadThroughIt() + { + using TestDirectory directory = new(); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + Path.Combine(directory.Path, "Package")); + + Action resolve = () => DeploymentFilePlanner.ResolveDeploymentFiles( + new[] { new DeploymentPackage(junction.JunctionPath, 0) }); + + resolve.Should().Throw(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void GetDirectoriesToCreate_ReturnsMissingDirectoriesParentFirst() + { + using TestDirectory directory = new(); + string gameRoot = directory.CreateDirectory("Game"); + string targetDirectory = Path.Combine(gameRoot, "Data", "Sub"); + + IEnumerable directories = DeploymentFilePlanner.GetDirectoriesToCreate(gameRoot, targetDirectory); + + directories.Should().Equal( + Path.Combine(gameRoot, "Data"), + targetDirectory); + } + + [Fact] + public void GetDirectoriesToCreate_WhenTargetDirectoryExists_ReturnsNothing() + { + using TestDirectory directory = new(); + string gameRoot = directory.CreateDirectory("Game"); + string targetDirectory = Directory.CreateDirectory(Path.Combine(gameRoot, "Data", "Sub")).FullName; + + IEnumerable directories = DeploymentFilePlanner.GetDirectoriesToCreate(gameRoot, targetDirectory); + + directories.Should().BeEmpty(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Support/DeploymentPathResolverTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Support/DeploymentPathResolverTests.cs new file mode 100644 index 00000000..65ebd0cc --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Support/DeploymentPathResolverTests.cs @@ -0,0 +1,125 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Launching.Support; + +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")] + [InlineData(" Data/INI/GameData.ini ", " Data/INI/GameData.ini ")] + public void NormalizeManifestPath_NormalizesSeparators(string relativePath, string expectedPath) + { + string result = DeploymentPathResolver.NormalizeManifestPath(relativePath); + + result.Should().Be(expectedPath); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void NormalizeManifestPath_RejectsMissingPaths(string relativePath) + { + Action act = () => DeploymentPathResolver.NormalizeManifestPath(relativePath); + + act.Should().Throw(); + } + + [Theory] + [InlineData(@"C:\Game\Data\GameData.ini", "Deployment manifest paths must be relative.")] + [InlineData("C:Game/Data/GameData.ini", "Deployment manifest paths must be relative.")] + [InlineData("../Data/GameData.ini", "Deployment manifest paths must not contain parent directory segments.")] + [InlineData("./Data/GameData.ini", "Deployment manifest paths must not contain parent directory segments.")] + public void NormalizeManifestPath_RejectsUnsafePaths(string relativePath, string expectedMessage) + { + Action act = () => DeploymentPathResolver.NormalizeManifestPath(relativePath); + + act.Should().Throw() + .WithMessage(expectedMessage); + } + + [Fact] + public void ResolveGamePath_ReturnsPathInsideGameDirectory() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(Path.Combine(directory.Path, "Game")); + + string result = DeploymentPathResolver.ResolveGamePath(paths, @"Data\GameData.ini"); + + result.Should().Be(Path.GetFullPath(Path.Combine(paths.GameDirectory, "Data", "GameData.ini"))); + } + + [Fact] + public void ResolveGamePath_RejectsLauncherOwnedPaths() + { + using TestDirectory directory = new(); + string gameDirectory = directory.CreateDirectory("Game"); + string executableDirectory = directory.CreateDirectory(Path.Combine("Game", "GenLauncherGO")); + LauncherPaths paths = new LauncherStoragePaths(executableDirectory) + .CreateGamePaths(SupportedGame.ZeroHour, gameDirectory); + string launcherOwnedPath = Path.GetRelativePath( + paths.GameDirectory, + Path.Combine(paths.RuntimeDirectory, "state.yaml")); + + Action act = () => DeploymentPathResolver.ResolveGamePath( + paths, + launcherOwnedPath); + + act.Should().Throw() + .WithMessage("*outside the game directory*"); + } + + [Fact] + public void ToRelativeManifestPath_ReturnsNormalizedChildPath() + { + using TestDirectory directory = new(); + string rootDirectory = Path.Combine(directory.Path, "Package"); + string path = Path.Combine(rootDirectory, "Data", "GameData.ini"); + + string result = DeploymentPathResolver.ToRelativeManifestPath(rootDirectory, path); + + result.Should().Be("Data/GameData.ini"); + } + + [Fact] + public void ToRelativeManifestPath_RejectsPathsOutsideRoot() + { + using TestDirectory directory = new(); + string rootDirectory = Path.Combine(directory.Path, "Package"); + string path = Path.Combine(directory.Path, "Other", "GameData.ini"); + + Action act = () => DeploymentPathResolver.ToRelativeManifestPath(rootDirectory, path); + + act.Should().Throw(); + } + + [Fact] + public void ResolveDeploymentStatePath_ReturnsPathInsideDeploymentDirectory() + { + using TestDirectory directory = new(); + string deploymentDirectory = Path.Combine(directory.Path, "Deployment"); + + string result = DeploymentPathResolver.ResolveDeploymentStatePath( + deploymentDirectory, + @"Records\manifest.yaml"); + + result.Should().Be(Path.GetFullPath(Path.Combine(deploymentDirectory, "Records", "manifest.yaml"))); + } + + [Fact] + public void ResolveDeploymentStatePath_RejectsPathsOutsideDeploymentDirectory() + { + using TestDirectory directory = new(); + string deploymentDirectory = Path.Combine(directory.Path, "Deployment"); + + Action act = () => DeploymentPathResolver.ResolveDeploymentStatePath( + deploymentDirectory, + "../manifest.yaml"); + + act.Should().Throw(); + } + +} diff --git a/GenLauncherGO.Tests/Infrastructure/Launching/Support/WindowsHardLinkCreatorTests.cs b/GenLauncherGO.Tests/Infrastructure/Launching/Support/WindowsHardLinkCreatorTests.cs new file mode 100644 index 00000000..60c520c5 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Launching/Support/WindowsHardLinkCreatorTests.cs @@ -0,0 +1,109 @@ +using System.IO; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Launching.Support; + +namespace GenLauncherGO.Tests.Infrastructure.Launching.Support; + +public sealed class WindowsHardLinkCreatorTests +{ + private const string ExtendedLengthPrefix = @"\\?\"; + + [Fact] + public void ArePathsOnSameVolume_ReturnsTrueForFilesUnderSameVolume() + { + using TestDirectory directory = new(); + string firstPath = Path.Combine(directory.Path, "First", "one.big"); + string secondPath = Path.Combine(directory.Path, "Second", "two.big"); + Directory.CreateDirectory(Path.GetDirectoryName(firstPath)!); + Directory.CreateDirectory(Path.GetDirectoryName(secondPath)!); + WindowsHardLinkCreator creator = new(); + + bool sameVolume = creator.ArePathsOnSameVolume(firstPath, secondPath); + + sameVolume.Should().BeTrue(); + } + + [Fact] + public void TryCreateHardLink_CreatesNonReparseHardLinkToExistingFile() + { + 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(); + + bool created = creator.TryCreateHardLink(targetPath, sourcePath); + + created.Should().BeTrue(); + File.Exists(targetPath).Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("package"); + File.GetAttributes(targetPath).Should().NotHaveFlag(FileAttributes.ReparsePoint); + + File.WriteAllText(sourcePath, "updated through source"); + File.ReadAllText(targetPath).Should().Be("updated through source"); + + File.WriteAllText(targetPath, "updated through target"); + File.ReadAllText(sourcePath).Should().Be("updated through target"); + } + + [Fact] + public void TryCreateHardLink_ReturnsFalseWhenSourceIsMissing() + { + using TestDirectory directory = new(); + string sourcePath = Path.Combine(directory.Path, "missing.big"); + string targetPath = Path.Combine(directory.Path, "target.big"); + WindowsHardLinkCreator creator = new(); + + bool created = creator.TryCreateHardLink(targetPath, sourcePath); + + created.Should().BeFalse(); + File.Exists(targetPath).Should().BeFalse(); + } + + /// + /// A caller may already hold Win32 extended-length paths, which must not be prefixed a second time. + /// + [Fact] + public void TryCreateHardLink_CreatesLinkWhenPathsAreAlreadyExtendedLength() + { + 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(); + + bool created = creator.TryCreateHardLink( + ExtendedLengthPrefix + targetPath, + ExtendedLengthPrefix + sourcePath); + + created.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("package"); + PhysicalDirectoryPath.GetFileIdentity(targetPath).Should() + .Be(PhysicalDirectoryPath.GetFileIdentity(sourcePath)); + } + + [Fact] + public void TryCreateHardLink_CreatesLinkWhenSourcePathExceedsLegacyMaxPath() + { + using TestDirectory directory = new(); + string longDirectory = directory.Path; + while (longDirectory.Length < 275) + { + longDirectory = Path.Combine(longDirectory, "long-package-directory"); + } + + Directory.CreateDirectory(longDirectory); + string sourcePath = Path.Combine(longDirectory, "source.big"); + string targetPath = Path.Combine(directory.Path, "target.big"); + File.WriteAllText(sourcePath, "package"); + WindowsHardLinkCreator creator = new(); + + bool created = creator.TryCreateHardLink(targetPath, sourcePath); + + sourcePath.Length.Should().BeGreaterThan(260); + created.Should().BeTrue(); + File.ReadAllText(targetPath).Should().Be("package"); + PhysicalDirectoryPath.GetFileIdentity(targetPath).Should() + .Be(PhysicalDirectoryPath.GetFileIdentity(sourcePath)); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Logging/LoggingServiceCollectionExtensionsTests.cs b/GenLauncherGO.Tests/Infrastructure/Logging/LoggingServiceCollectionExtensionsTests.cs new file mode 100644 index 00000000..37af05da --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Logging/LoggingServiceCollectionExtensionsTests.cs @@ -0,0 +1,228 @@ +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.Logging; + +public sealed partial class LoggingServiceCollectionExtensionsTests +{ + [Theory] + [InlineData(false, false)] + [InlineData(true, true)] + public void AddGenLauncherGoLogging_DiagnosticSettingControlsDebugEvents( + bool enableDiagnosticLogging, + bool expectDebugEvent) + { + using TestDirectory directory = new(); + string logDirectory = directory.GetPath("Logs"); + var services = new ServiceCollection(); + + services.AddGenLauncherGoLogging(logDirectory, enableDiagnosticLogging); + using (ServiceProvider provider = services.BuildServiceProvider()) + { + provider + .GetRequiredService>() + .LogDebug("Detailed diagnostic event"); + } + + string logText = File.ReadAllText(Directory.GetFiles(logDirectory, "GenLauncherGO-*.log").Single()); + logText.Contains("Detailed diagnostic event", StringComparison.Ordinal).Should().Be(expectDebugEvent); + } + + [Fact] + public void AddGenLauncherGoLogging_CreatesReadableSessionLog() + { + using TestDirectory directory = new(); + string logDirectory = directory.GetPath("Logs"); + + 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); + } + + Directory.Exists(logDirectory).Should().BeTrue(); + string[] logFiles = Directory.GetFiles(logDirectory, "GenLauncherGO-*.log"); + logFiles.Should().HaveCount(2); + logFiles.Should().OnlyContain(file => + SessionLogFileNamePattern().IsMatch(Path.GetFileName(file))); + } + + [Fact] + public void AddGenLauncherGoLogging_PrunesOldSessionLogs() + { + using TestDirectory directory = new(); + string logDirectory = directory.CreateDirectory("Logs"); + string[] oldLogFileNames = Enumerable.Range(1, 20) + .Select(day => $"GenLauncherGO-2026-01-{day:00}-120000Z.log") + .ToArray(); + for (int index = 0; index < oldLogFileNames.Length; index++) + { + string logFilePath = Path.Combine(logDirectory, oldLogFileNames[index]); + File.WriteAllText(logFilePath, "old"); + File.SetLastWriteTimeUtc(logFilePath, DateTime.UtcNow.AddMinutes(-index - 1)); + } + + var services = new ServiceCollection(); + + services.AddGenLauncherGoLogging(logDirectory); + using (ServiceProvider provider = services.BuildServiceProvider()) + { + provider + .GetRequiredService>() + .LogInformation("Current session"); + } + + string[] retainedLogFileNames = Directory.GetFiles(logDirectory, "*.log") + .Select(file => Path.GetFileName(file)) + .ToArray(); + retainedLogFileNames.Should().HaveCount(14); + retainedLogFileNames.Should().Contain(oldLogFileNames.Take(13)); + retainedLogFileNames.Should().NotContain(oldLogFileNames.Skip(13)); + retainedLogFileNames.Except(oldLogFileNames).Should().ContainSingle(); + } + + /// + /// The retained logs are the most recent sessions, which the name of a log file does not decide: a restored + /// backup, a corrected clock, or a copied folder all leave names and ages disagreeing, and the sessions worth + /// keeping are still the ones that ran last. + /// + [Fact] + public void AddGenLauncherGoLogging_RetainsTheMostRecentlyWrittenLogs() + { + using TestDirectory directory = new(); + string logDirectory = directory.CreateDirectory("Logs"); + string[] logFileNamesOldestFirst = Enumerable.Range(1, 20) + .Select(day => $"GenLauncherGO-2026-01-{day:00}-120000Z.log") + .ToArray(); + for (int index = 0; index < logFileNamesOldestFirst.Length; index++) + { + string logFilePath = Path.Combine(logDirectory, logFileNamesOldestFirst[index]); + File.WriteAllText(logFilePath, "old"); + File.SetLastWriteTimeUtc( + logFilePath, + DateTime.UtcNow.AddMinutes(index - logFileNamesOldestFirst.Length)); + } + + var services = new ServiceCollection(); + + services.AddGenLauncherGoLogging(logDirectory); + using (ServiceProvider provider = services.BuildServiceProvider()) + { + provider + .GetRequiredService>() + .LogInformation("Current session"); + } + + string[] retainedLogFileNames = Directory.GetFiles(logDirectory, "*.log") + .Select(file => Path.GetFileName(file)) + .ToArray(); + retainedLogFileNames.Should().Contain(logFileNamesOldestFirst.Skip(7)); + retainedLogFileNames.Should().NotContain(logFileNamesOldestFirst.Take(7)); + } + + /// + /// The log folder is somewhere a user browses to and drops files. Pruning is scoped to the launcher's own + /// session logs by name, so the oldest thing in the folder is not automatically the next thing deleted. + /// + [Fact] + public void AddGenLauncherGoLogging_PrunesOnlyItsOwnSessionLogs() + { + using TestDirectory directory = new(); + string logDirectory = directory.CreateDirectory("Logs"); + string foreignFilePath = Path.Combine(logDirectory, "crash-report.txt"); + File.WriteAllText(foreignFilePath, "kept"); + File.SetLastWriteTimeUtc(foreignFilePath, DateTime.UtcNow.AddDays(-30)); + for (int day = 1; day <= 20; day++) + { + string logFilePath = Path.Combine(logDirectory, $"GenLauncherGO-2026-01-{day:00}-120000Z.log"); + File.WriteAllText(logFilePath, "old"); + File.SetLastWriteTimeUtc(logFilePath, DateTime.UtcNow.AddMinutes(-day)); + } + + var services = new ServiceCollection(); + + services.AddGenLauncherGoLogging(logDirectory); + using (ServiceProvider provider = services.BuildServiceProvider()) + { + provider + .GetRequiredService>() + .LogInformation("Current session"); + } + + File.ReadAllText(foreignFilePath).Should().Be("kept"); + } + + [Fact] + public void AddGenLauncherGoLogging_RedactsLocalPathsAndSensitiveQueryValues() + { + using TestDirectory directory = new(); + string logDirectory = directory.GetPath("Logs"); + var services = new ServiceCollection(); + 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"; + + 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); + } + + 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"); + } + + [Fact] + public void AddGenLauncherGoLogging_RedactsUncAndForwardSlashWindowsPaths() + { + using TestDirectory directory = new(); + string logDirectory = directory.GetPath("Logs"); + var services = new ServiceCollection(); + + services.AddGenLauncherGoLogging(logDirectory); + using (ServiceProvider provider = services.BuildServiceProvider()) + { + provider + .GetRequiredService>() + .LogWarning( + "Could not read {ForwardSlashPath} or {UncPath}.", + "C:/Users/Alice Example/Secrets/file.txt", + @"\\fileserver\profiles\Bob Example\Secrets\file.txt"); + } + + string logText = File.ReadAllText(Directory.GetFiles(logDirectory, "GenLauncherGO-*.log").Single()); + logText.Should().Contain("[local path]"); + logText.Should().NotContain("Alice Example"); + logText.Should().NotContain("Bob Example"); + logText.Should().NotContain("fileserver"); + } + + [GeneratedRegex(@"^GenLauncherGO-\d{4}-\d{2}-\d{2}-\d{6}Z(-\d+)?\.log$")] + private static partial Regex SessionLogFileNamePattern(); +} diff --git a/GenLauncherGO.Tests/Infrastructure/Logging/SensitiveDataRedactingTextFormatterTests.cs b/GenLauncherGO.Tests/Infrastructure/Logging/SensitiveDataRedactingTextFormatterTests.cs new file mode 100644 index 00000000..ec10c872 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Logging/SensitiveDataRedactingTextFormatterTests.cs @@ -0,0 +1,260 @@ +using System; +using System.Globalization; +using System.IO; +using System.Linq; +using GenLauncherGO.Infrastructure.Logging; +using Serilog.Events; +using Serilog.Parsing; + +namespace GenLauncherGO.Tests.Infrastructure.Logging; + +/// +/// The formatter is the last step before a log event reaches a file a user attaches to a bug report, so each test +/// asserts both that the private value is gone and that the diagnostics around it survived. +/// +public sealed class SensitiveDataRedactingTextFormatterTests +{ + private static readonly MessageTemplateParser _messageTemplateParser = new(); + + private static readonly DateTimeOffset _eventTimestamp = + new(2026, 1, 2, 3, 4, 5, 678, TimeSpan.FromHours(2)); + + /// + /// A user's folder names carry their real name and their installed software, so no absolute local path may + /// survive in any of the spellings Windows accepts. + /// + [Theory] + [InlineData(@"C:\Users\Alice Example\Secrets\file.txt", "Alice Example")] + [InlineData("C:/Users/Alice Example/Secrets/file.txt", "Alice Example")] + [InlineData(@"d:\Games\Alice Example\game.dat", "Alice Example")] + [InlineData(@"\\fileserver\profiles\Alice Example\file.txt", "fileserver")] + public void Format_AbsoluteLocalPath_ReplacesPathAndKeepsSurroundingMessage( + string path, + string privateSegment) + { + var formatter = new SensitiveDataRedactingTextFormatter(); + using var output = new StringWriter(CultureInfo.InvariantCulture); + LogEvent logEvent = CreateLogEvent("Could not read {Path} for the active profile.", ("Path", path)); + + formatter.Format(logEvent, output); + + string text = output.ToString(); + text.Should().Contain("[local path]"); + text.Should().Contain("for the active profile."); + text.Should().NotContain(privateSegment); + } + + /// + /// Redaction has to stop at absolute local paths: a message that carries none must reach the log intact, or the + /// log stops being useful for support. + /// + [Fact] + public void Format_MessageWithoutLocalPath_WritesTheRenderedMessageUnchanged() + { + var formatter = new SensitiveDataRedactingTextFormatter(); + using var output = new StringWriter(CultureInfo.InvariantCulture); + LogEvent logEvent = CreateLogEvent( + "Installed {Count} packages from {RelativePath}.", + ("Count", 3), + ("RelativePath", "Data/INI/GameData.ini")); + + formatter.Format(logEvent, output); + + string text = output.ToString(); + text.Should().Contain("Installed 3 packages"); + text.Should().Contain("Data/INI/GameData.ini"); + } + + /// + /// A download URL is logged whenever a transfer fails, and its user-info segment is a live credential. The host + /// has to stay so the failing mirror is still identifiable. + /// + [Fact] + public void Format_UriUserInfo_ReplacesCredentialsAndKeepsTheHost() + { + var formatter = new SensitiveDataRedactingTextFormatter(); + using var output = new StringWriter(CultureInfo.InvariantCulture); + LogEvent logEvent = CreateLogEvent( + "Download from {Uri} failed.", + ("Uri", "https://alice:s3cret-value@packages.example.test/mod.zip")); + + formatter.Format(logEvent, output); + + string text = output.ToString(); + text.Should().Contain("https://[redacted]@packages.example.test/mod.zip"); + text.Should().NotContain("s3cret-value"); + text.Should().NotContain("alice"); + } + + /// + /// Presigned S3 links and OAuth callbacks put live credentials in the query string. The parameter name must + /// survive so a reader can see which credential was in play, and unrelated parameters must survive with it. + /// + [Theory] + [InlineData("access_token")] + [InlineData("access-token")] + [InlineData("accesstoken")] + [InlineData("api_key")] + [InlineData("apikey")] + [InlineData("credential")] + [InlineData("secret")] + [InlineData("token")] + [InlineData("session_token")] + [InlineData("security_token")] + [InlineData("password")] + [InlineData("signature")] + [InlineData("sig")] + [InlineData("X-Amz-Credential")] + [InlineData("X-Amz-Signature")] + [InlineData("X-Amz-Security-Token")] + public void Format_SensitiveQueryParameter_ReplacesValueAndKeepsKeyAndSafeParameters(string parameterName) + { + var formatter = new SensitiveDataRedactingTextFormatter(); + using var output = new StringWriter(CultureInfo.InvariantCulture); + LogEvent logEvent = CreateLogEvent( + "Requested {Uri}.", + ("Uri", $"https://packages.example.test/mod.zip?{parameterName}=s3cret-value&name=safe")); + + formatter.Format(logEvent, output); + + string text = output.ToString(); + text.Should().Contain($"{parameterName}=[redacted]"); + text.Should().Contain("name=safe"); + text.Should().NotContain("s3cret-value"); + } + + /// + /// An exception is the payload of most bug reports, so its type and message have to reach the log on their own + /// line even though the paths inside them do not. + /// + [Fact] + public void Format_EventWithException_WritesRedactedExceptionBelowTheMessage() + { + var formatter = new SensitiveDataRedactingTextFormatter(); + using var output = new StringWriter(CultureInfo.InvariantCulture); + LogEvent logEvent = CreateLogEvent( + "Launch preparation failed.", + new InvalidOperationException(@"Could not stage C:\Users\Alice Example\Deploy")); + + formatter.Format(logEvent, output); + + string[] lines = ReadLines(output); + lines.Should().HaveCount(2); + lines[0].Should().EndWith("Launch preparation failed."); + lines[1].Should().Contain(nameof(InvalidOperationException)); + lines[1].Should().Contain("[local path]"); + lines[1].Should().NotContain("Alice Example"); + } + + [Fact] + public void Format_EventWithoutException_WritesOnlyTheMessageLine() + { + var formatter = new SensitiveDataRedactingTextFormatter(); + using var output = new StringWriter(CultureInfo.InvariantCulture); + LogEvent logEvent = CreateLogEvent("Launch preparation succeeded."); + + formatter.Format(logEvent, output); + + ReadLines(output).Should().ContainSingle() + .Which.Should().EndWith("Launch preparation succeeded."); + } + + /// + /// A stack frame names the source file on the machine that built the launcher, which is a private path like any + /// other. The line number is the part that makes the frame worth logging, so it has to outlive the path. + /// + [Fact] + public void Format_StackFrameSourcePath_ReplacesPathAndKeepsLineNumber() + { + var formatter = new SensitiveDataRedactingTextFormatter(); + using var output = new StringWriter(CultureInfo.InvariantCulture); + LogEvent logEvent = CreateLogEvent( + @" at GenLauncherGO.Launch() in C:\build\Alice Example\Launcher.cs:line 4711"); + + formatter.Format(logEvent, output); + + string text = output.ToString(); + text.Should().Contain("[local source]:line 4711"); + text.Should().NotContain("Alice Example"); + } + + /// + /// Log files are read in bulk and filtered by level, so every level has to reach the file as its own marker. + /// + [Theory] + [InlineData(LogEventLevel.Verbose, "VRB")] + [InlineData(LogEventLevel.Debug, "DBG")] + [InlineData(LogEventLevel.Information, "INF")] + [InlineData(LogEventLevel.Warning, "WRN")] + [InlineData(LogEventLevel.Error, "ERR")] + [InlineData(LogEventLevel.Fatal, "FTL")] + public void Format_EachLevel_WritesItsOwnLevelMarker(LogEventLevel level, string expectedMarker) + { + var formatter = new SensitiveDataRedactingTextFormatter(); + using var output = new StringWriter(CultureInfo.InvariantCulture); + LogEvent logEvent = CreateLogEvent("Ready.", level); + + formatter.Format(logEvent, output); + + output.ToString().Should().Contain($"[{expectedMarker}] Ready."); + } + + /// + /// Support reads these files beside Windows event logs, so each line has to carry a sortable timestamp whose + /// offset makes it comparable with events recorded in another time zone. + /// + [Fact] + public void Format_Timestamp_WritesTheEventTimestampWithItsOffset() + { + var formatter = new SensitiveDataRedactingTextFormatter(); + using var output = new StringWriter(CultureInfo.InvariantCulture); + LogEvent logEvent = CreateLogEvent("Ready."); + + formatter.Format(logEvent, output); + + string timestampText = ReadLines(output)[0].Split(" [", StringSplitOptions.None)[0]; + DateTimeOffset.ParseExact( + timestampText, + "yyyy-MM-dd HH:mm:ss.fff zzz", + CultureInfo.InvariantCulture) + .Should().Be(_eventTimestamp); + } + + private static string[] ReadLines(StringWriter output) + { + return output.ToString().Split( + Environment.NewLine, + StringSplitOptions.RemoveEmptyEntries); + } + + private static LogEvent CreateLogEvent( + string messageTemplate, + params (string Name, object? Value)[] properties) + { + return CreateLogEvent(messageTemplate, LogEventLevel.Information, null, properties); + } + + private static LogEvent CreateLogEvent(string messageTemplate, LogEventLevel level) + { + return CreateLogEvent(messageTemplate, level, null); + } + + private static LogEvent CreateLogEvent(string messageTemplate, Exception exception) + { + return CreateLogEvent(messageTemplate, LogEventLevel.Information, exception); + } + + private static LogEvent CreateLogEvent( + string messageTemplate, + LogEventLevel level, + Exception? exception, + params (string Name, object? Value)[] properties) + { + return new LogEvent( + _eventTimestamp, + level, + exception, + _messageTemplateParser.Parse(messageTemplate), + properties.Select(property => new LogEventProperty(property.Name, new ScalarValue(property.Value)))); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemLocalLauncherContentServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemLocalLauncherContentServiceTests.cs new file mode 100644 index 00000000..6287f7d8 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemLocalLauncherContentServiceTests.cs @@ -0,0 +1,437 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class FileSystemLocalLauncherContentServiceTests +{ + [Fact] + public void FindInstalledVersions_ReturnsInstalledModsPatchesAndAddons() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + Directory.CreateDirectory(Path.Combine(paths.ModsDirectory, "ShockWave", "1.2", "Data", "Empty")); + CreateFile(Path.Combine(paths.ModsDirectory, "ShockWave", "1.2", "Data", "Real", "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")); + + IReadOnlyList versions = service.FindInstalledVersions(paths); + + versions.Should().HaveCount(3); + versions.Should().ContainSingle(version => + version.ModificationType == ModificationType.Mod && + version.Name == "ShockWave" && + version.Version == "1.2" && + version.Installation.Installed); + versions.Should().ContainSingle(version => + version.ModificationType == ModificationType.Addon && + version.Name == "HD" && + version.Version == "1.0" && + version.ParentContentName == "ShockWave" && + version.Installation.Installed); + versions.Should().ContainSingle(version => + version.ModificationType == ModificationType.Patch && + version.Name == "Balance" && + version.Version == "2.0" && + version.ParentContentName == "ShockWave" && + version.Installation.Installed); + versions.Should().NotContain(version => version.Name == "EmptyMod"); + } + + /// + /// A linked Mods tree is refused outright rather than walked, because the versions it would report are folders + /// the launcher does not own and would later delete on the user's behalf. + /// + [Fact] + public void FindInstalledVersions_RefusesModsTreeContainingLinkedDirectory() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + FileSystemLocalLauncherContentService service = CreateService(); + CreateFile(Path.Combine(paths.ModsDirectory, "ShockWave", "1.2", "Data", "INI.big")); + string outsideDirectory = directory.CreateDirectory("OutsideMods"); + string outsideVersionFile = directory.CreateFile("OutsideMods/1.0/Outside.big", "outside"); + ReparsePointTestSupport.CreateDirectoryJunction( + Path.Combine(paths.ModsDirectory, "Linked"), + outsideDirectory); + + Action act = () => service.FindInstalledVersions(paths); + + act.Should().Throw(); + File.ReadAllText(outsideVersionFile).Should().Be("outside"); + } + + /// + /// A launcher-owned data root that is itself a link resolves to a tree the launcher never created, and every + /// version reported from it would later be deleted on the user's behalf, so the scan is refused before it + /// walks a single folder. + /// + [Fact] + public void FindInstalledVersions_RefusesModsTreeReachedThroughALinkedAncestor() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + FileSystemLocalLauncherContentService service = CreateService(); + Directory.Delete(paths.OwnedGameDataDirectory, true); + string outsideDirectory = directory.CreateDirectory("OutsideData"); + string outsideVersionFile = directory.CreateFile("OutsideData/Mods/ShockWave/1.2/Outside.big", "outside"); + ReparsePointTestSupport.CreateDirectoryJunction(paths.OwnedGameDataDirectory, outsideDirectory); + + Action act = () => service.FindInstalledVersions(paths); + + act.Should().Throw(); + File.ReadAllText(outsideVersionFile).Should().Be("outside"); + } + + [Fact] + public void FindInstalledVersions_ReturnsNothingWhenTheModsFolderHasNotBeenCreated() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + + IReadOnlyList versions = service.FindInstalledVersions(paths); + + versions.Should().BeEmpty(); + } + + /// + /// An empty version folder is a leftover, not an installation. That holds for a patch or add-on version folder + /// exactly as it does for a modification's own, or the launcher would offer to launch content with no files. + /// + [Fact] + public void FindInstalledVersions_IgnoresEmptyChildVersionDirectories() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + CreateFile(Path.Combine(paths.ModsDirectory, "ShockWave", "1.2", "INI.big")); + Directory.CreateDirectory(Path.Combine(paths.ModsDirectory, "ShockWave", "Addons", "HD", "1.0")); + Directory.CreateDirectory(Path.Combine(paths.ModsDirectory, "ShockWave", "Patches", "Balance", "2.0")); + + IReadOnlyList versions = service.FindInstalledVersions(paths); + + versions.Should().ContainSingle().Which.Name.Should().Be("ShockWave"); + } + + [Fact] + public void DeleteVersion_DeletesVersionAndPrunesEmptyParents() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(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 LauncherContentVersion + { + ModificationType = ModificationType.Addon, + Name = "HD", + Version = "1.0", + ParentContentName = "ShockWave" + }; + + service.DeleteVersion(paths, version.ContentKey); + + Directory.Exists(versionDirectory).Should().BeFalse(); + Directory.Exists(Path.Combine(paths.ModsDirectory, "ShockWave")).Should().BeFalse(); + Directory.Exists(paths.ModsDirectory).Should().BeTrue(); + } + + [Fact] + public void DeleteVersion_DeletesPackageStagingAndRecoveryFoldersWhenInstalledFolderIsMissing() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string versionDirectory = Path.Combine(paths.ModsDirectory, "ShockWave", "1.2"); + var installedPath = new OwnedContentPath(paths.ModsDirectory, versionDirectory); + string packageStagingDirectory = paths.GetPackageTemporaryPath(installedPath).FullPath; + OwnedContentPath packageBackupPath = paths.GetPackageBackupPath(installedPath); + CreateFile(Path.Combine(packageStagingDirectory, "Data", "INI.big")); + CreateFile(Path.Combine(packageBackupPath.FullPath, "Data", "OldINI.big")); + var version = new LauncherContentVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2" + }; + + service.DeleteVersion(paths, version.ContentKey); + + Directory.Exists(packageStagingDirectory).Should().BeFalse(); + Directory.Exists(Path.Combine(paths.TempDirectory, "Packages", "ShockWave")).Should().BeFalse(); + Directory.Exists(packageBackupPath.FullPath).Should().BeFalse(); + Directory.Exists(Path.Combine(packageBackupPath.OwnerRoot, "ShockWave")).Should().BeFalse(); + Directory.Exists(packageBackupPath.OwnerRoot).Should().BeFalse(); + Directory.Exists(versionDirectory).Should().BeFalse(); + } + + [Fact] + public void DeleteVersion_DeletesPackageStagingFolderForChildContentWhenInstalledFolderIsMissing() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string versionDirectory = Path.Combine(paths.ModsDirectory, "ShockWave", "Addons", "HD", "1.0"); + string packageStagingDirectory = paths.GetPackageTemporaryPath( + new OwnedContentPath(paths.ModsDirectory, versionDirectory)).FullPath; + CreateFile(Path.Combine(packageStagingDirectory, "HD.big")); + var version = new LauncherContentVersion + { + ModificationType = ModificationType.Addon, + Name = "HD", + Version = "1.0", + ParentContentName = "ShockWave" + }; + + service.DeleteVersion(paths, version.ContentKey); + + Directory.Exists(packageStagingDirectory).Should().BeFalse(); + Directory.Exists(Path.Combine(paths.TempDirectory, "Packages", "ShockWave")).Should().BeFalse(); + Directory.Exists(versionDirectory).Should().BeFalse(); + } + + [Fact] + public void DeleteContent_DeletesModRootAndPackageStagingAndRecoveryRoots() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string contentDirectory = Path.Combine(paths.ModsDirectory, "ShockWave"); + var installedPath = new OwnedContentPath(paths.ModsDirectory, contentDirectory); + string packageStagingDirectory = paths.GetPackageTemporaryPath(installedPath).FullPath; + OwnedContentPath packageBackupPath = paths.GetPackageBackupPath(installedPath); + 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")); + CreateFile(Path.Combine(packageBackupPath.FullPath, "1.2", "Data", "OldINI.big")); + var version = new LauncherContentVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2" + }; + + service.DeleteContent(paths, version.ContentKey); + + Directory.Exists(contentDirectory).Should().BeFalse(); + Directory.Exists(packageStagingDirectory).Should().BeFalse(); + Directory.Exists(packageBackupPath.FullPath).Should().BeFalse(); + Directory.Exists(packageBackupPath.OwnerRoot).Should().BeFalse(); + Directory.Exists(paths.ModsDirectory).Should().BeTrue(); + } + + [Fact] + public void DeleteContent_DeletesChildContentRoot() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(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 LauncherContentVersion + { + ModificationType = ModificationType.Addon, + Name = "HD", + Version = "1.0", + ParentContentName = "ShockWave" + }; + + service.DeleteContent(paths, version.ContentKey); + + Directory.Exists(contentDirectory).Should().BeFalse(); + Directory.Exists(Path.Combine(paths.ModsDirectory, "ShockWave")).Should().BeFalse(); + } + + [Fact] + public void DeleteEmptyPackageBackupDirectories_RemovesOnlyEmptyRecoveryDirectories() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string emptyBackupDirectory = Path.Combine(paths.PackageBackupsDirectory, "Unused", "1.0"); + string retainedBackupFile = Path.Combine(paths.PackageBackupsDirectory, "Active", "1.0", "asset.big"); + Directory.CreateDirectory(emptyBackupDirectory); + CreateFile(retainedBackupFile); + + service.DeleteEmptyPackageBackupDirectories(paths); + + Directory.Exists(emptyBackupDirectory).Should().BeFalse(); + File.Exists(retainedBackupFile).Should().BeTrue(); + Directory.Exists(paths.PackageBackupsDirectory).Should().BeTrue(); + } + + /// + /// Removing a version the launcher does not have installed deletes nothing, so it must not prune folders as a + /// side effect either: the tree is only tidied after something was actually removed from it. + /// + [Fact] + public void DeleteVersion_LeavesTheContentFolderWhenTheVersionIsNotInstalled() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string contentDirectory = Path.Combine(paths.ModsDirectory, "ShockWave"); + Directory.CreateDirectory(contentDirectory); + var version = new LauncherContentVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2" + }; + + service.DeleteVersion(paths, version.ContentKey); + + Directory.Exists(contentDirectory).Should().BeTrue(); + } + + [Fact] + public void DeleteVersion_RefusesPathOutsideModsRoot() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + var version = new LauncherContentVersion + { + ModificationType = ModificationType.Mod, + Name = "..", + Version = "Outside" + }; + + Action act = () => service.DeleteVersion(paths, version.ContentKey); + + act.Should().Throw(); + } + + [Fact] + public void DeleteImagesIfUnused_DeletesOwnedCacheFolderWhenNoCardReferencesContentName() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + string cardImage = Path.Combine(imageDirectory, "1.2.png"); + string backgroundImage = Path.Combine( + imageDirectory, + LauncherContentTheme.ResolveBackgroundImageBaseName("1.2") + ".jpg"); + string cachedTheme = Path.Combine( + imageDirectory, + LauncherContentTheme.ResolveCacheBaseName("1.2") + ".yaml"); + string otherImage = Path.Combine(imageDirectory, "readme.txt"); + CreateFile(cardImage); + CreateFile(backgroundImage); + CreateFile(cachedTheme); + CreateFile(otherImage); + var version = new LauncherContentVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2" + }; + + service.DeleteImagesIfUnused(paths, version.ContentKey, new LauncherData()); + + File.Exists(cardImage).Should().BeFalse(); + File.Exists(backgroundImage).Should().BeFalse(); + File.Exists(cachedTheme).Should().BeFalse(); + File.Exists(otherImage).Should().BeFalse(); + Directory.Exists(imageDirectory).Should().BeFalse(); + } + + [Fact] + public void DeleteImagesIfUnused_AdvertisingUsesTheCatalogCacheName() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string imageDirectory = paths.GetModificationImagesDirectory("Do you like GenLauncher"); + string imagePath = Path.Combine(imageDirectory, "0.jpg"); + CreateFile(imagePath); + var version = new LauncherContentVersion + { + ModificationType = ModificationType.Advertising, + Name = "Do you like GenLauncher?", + Version = "1.0" + }; + + service.DeleteImagesIfUnused(paths, version.ContentKey, new LauncherData()); + + Directory.Exists(imageDirectory).Should().BeFalse(); + } + + /// + /// Someone can point a modification's image cache at a folder of their own. Removing the content card must + /// still clear the cache entry, and must do it by unlinking rather than by deleting what is on the far side. + /// + [Fact] + public void DeleteImagesIfUnused_RemovesLinkedImageCacheWithoutDeletingItsTarget() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + FileSystemLocalLauncherContentService service = CreateService(); + string outsideDirectory = directory.CreateDirectory("OutsideImages"); + string firstOutsideImage = directory.CreateFile("OutsideImages/1.2.png", "first"); + string secondOutsideImage = directory.CreateFile("OutsideImages/holiday.png", "second"); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + ReparsePointTestSupport.CreateDirectoryJunction(imageDirectory, outsideDirectory); + var version = new LauncherContentVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2" + }; + + service.DeleteImagesIfUnused(paths, version.ContentKey, new LauncherData()); + + Directory.Exists(imageDirectory).Should().BeFalse(); + File.ReadAllText(firstOutsideImage).Should().Be("first"); + File.ReadAllText(secondOutsideImage).Should().Be("second"); + } + + [Fact] + public void DeleteImagesIfUnused_KeepsImagesWhenCardStillReferencesContentName() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + FileSystemLocalLauncherContentService service = CreateService(); + string imagePath = Path.Combine(paths.GetModificationImagesDirectory("ShockWave"), "1.2.png"); + CreateFile(imagePath); + var version = new LauncherContentVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2" + }; + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(new LauncherContentVersion + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.0" + }); + + service.DeleteImagesIfUnused(paths, version.ContentKey, launcherData); + + File.Exists(imagePath).Should().BeTrue(); + } + + private static FileSystemLocalLauncherContentService CreateService() + { + return new FileSystemLocalLauncherContentService( + NullLogger.Instance); + } + + 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..b162e59d --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemManualModificationImporterTests.cs @@ -0,0 +1,260 @@ +using System; +using System.IO; +using System.Threading; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Archives.Contracts; +using GenLauncherGO.Infrastructure.Mods.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class FileSystemManualModificationImporterTests +{ + [Fact] + public void Import_CopiesRegularFilesToDestination() + { + 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(); + + importer.Import(new[] { sourceFilePath }, CreateOwnedDestination(directory.Path, destinationDirectory), TestContext.Current.CancellationToken); + + File.ReadAllText(Path.Combine(destinationDirectory, "readme.txt")) + .Should().Be("manual content"); + File.Exists(sourceFilePath).Should().BeTrue(); + } + + [Fact] + public void Import_RenamesLooseBigFilesToGibFiles() + { + 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(); + + importer.Import(new[] { sourceFilePath }, CreateOwnedDestination(directory.Path, destinationDirectory), TestContext.Current.CancellationToken); + + File.Exists(Path.Combine(destinationDirectory, "package.big")).Should().BeFalse(); + File.ReadAllText(Path.Combine(destinationDirectory, "package.gib")) + .Should().Be("big content"); + } + + [Fact] + public void Import_CopiesLooseGibFilesToDestination() + { + 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"); + + FileSystemManualModificationImporter importer = CreateImporter(); + + importer.Import(new[] { sourceFilePath }, CreateOwnedDestination(directory.Path, destinationDirectory), TestContext.Current.CancellationToken); + + File.ReadAllText(Path.Combine(destinationDirectory, "package.gib")) + .Should().Be("gib content"); + File.Exists(sourceFilePath).Should().BeTrue(); + } + + /// + /// Re-importing over content that is already in place must not fail the whole selection, so a file that is + /// already present is left exactly as it is rather than copied over. + /// + [Fact] + public void Import_DoesNotFailWhenDestinationFileAlreadyExists() + { + using var directory = new TestDirectory(); + string sourceFilePath = directory.CreateFile("source/readme.txt", "manual content"); + string destinationDirectory = directory.CreateDirectory("destination"); + string destinationFilePath = directory.CreateFile("destination/readme.txt", "already imported"); + FileSystemManualModificationImporter importer = CreateImporter(); + + importer.Import(new[] { sourceFilePath }, CreateOwnedDestination(directory.Path, destinationDirectory), TestContext.Current.CancellationToken); + + File.ReadAllText(destinationFilePath).Should().Be("already imported"); + } + + [Theory] + [InlineData(".zip")] + [InlineData(".rar")] + [InlineData(".7z")] + public void Import_ExtractsArchivesAndDeletesStagedArchive(string extension) + { + using var directory = new TestDirectory(); + string sourceDirectory = Path.Combine(directory.Path, "source"); + string destinationDirectory = Path.Combine(directory.Path, "destination"); + Directory.CreateDirectory(sourceDirectory); + string archiveFileName = "package" + extension; + string sourceFilePath = Path.Combine(sourceDirectory, archiveFileName); + File.WriteAllText(sourceFilePath, "archive content"); + + RecordingArchiveExtractor archiveExtractor = new() { ExtractHandler = WriteExtractedFile }; + FileSystemManualModificationImporter importer = CreateImporter(archiveExtractor); + + importer.Import(new[] { sourceFilePath }, CreateOwnedDestination(directory.Path, destinationDirectory), TestContext.Current.CancellationToken); + + archiveExtractor.ArchiveFilePath.Should().Be(Path.Combine(destinationDirectory, archiveFileName)); + archiveExtractor.DestinationDirectory.Should().Be(destinationDirectory); + File.Exists(Path.Combine(destinationDirectory, archiveFileName)).Should().BeFalse(); + File.ReadAllText(Path.Combine(destinationDirectory, "extracted.txt")) + .Should().Be("extracted content"); + File.Exists(sourceFilePath).Should().BeTrue(); + } + + /// + /// A cancelled import is the user's own decision, so it stops at the next file and is never reported as a + /// failure. + /// + [Fact] + public void Import_StopsAtCancellationWithoutImportingRemainingFiles() + { + using var directory = new TestDirectory(); + string archiveFilePath = directory.CreateFile("source/package.zip", "archive content"); + string remainingFilePath = directory.CreateFile("source/readme.txt", "manual content"); + string destinationDirectory = Path.Combine(directory.Path, "destination"); + using CancellationTokenSource cancellation = new(); + RecordingArchiveExtractor archiveExtractor = new() { ExtractHandler = _ => cancellation.Cancel() }; + RecordingLogger logger = new(); + FileSystemManualModificationImporter importer = CreateImporter(archiveExtractor, logger); + + Action act = () => importer.Import( + new[] { archiveFilePath, remainingFilePath }, + CreateOwnedDestination(directory.Path, destinationDirectory), + cancellation.Token); + + act.Should().Throw(); + File.Exists(Path.Combine(destinationDirectory, "readme.txt")).Should().BeFalse(); + logger.Entries.Should().NotContain(entry => entry.LogLevel == LogLevel.Error); + } + + [Fact] + public void Import_RejectsEmptySourceFileList() + { + using var directory = new TestDirectory(); + FileSystemManualModificationImporter importer = CreateImporter(); + string destinationDirectory = Path.Combine(directory.Path, "destination"); + + Action act = () => importer.Import( + Array.Empty(), + CreateOwnedDestination(directory.Path, destinationDirectory)); + + act.Should().Throw() + .WithMessage("*At least one source file is required*"); + } + + /// + /// Import copies each selected file under its own name. A path that names no file has no name to copy under, + /// and would otherwise resolve onto the destination folder itself. + /// + [Fact] + public void Import_RejectsSourcePathThatNamesNoFile() + { + using var directory = new TestDirectory(); + string sourceDirectory = directory.CreateDirectory("source"); + string destinationDirectory = Path.Combine(directory.Path, "destination"); + FileSystemManualModificationImporter importer = CreateImporter(); + + Action act = () => importer.Import( + new[] { sourceDirectory + Path.DirectorySeparatorChar }, + CreateOwnedDestination(directory.Path, destinationDirectory)); + + act.Should().Throw() + .Which.ParamName.Should().Be("sourceFilePath"); + } + + [Fact] + public void ImportRequest_RejectsDestinationOutsideOwnershipBoundaryBeforeMutation() + { + using var directory = new TestDirectory(); + string sourceFilePath = directory.CreateFile("source/readme.txt", "manual content"); + string ownedRoot = directory.CreateDirectory("owned"); + string outsideDestination = Path.Combine(directory.Path, "outside", "1.0"); + FileSystemManualModificationImporter importer = CreateImporter(); + + Action act = () => importer.Import( + new[] { sourceFilePath }, + new OwnedContentPath(ownedRoot, outsideDestination)); + + act.Should().Throw() + .WithMessage("*below its owning root*"); + Directory.Exists(outsideDestination).Should().BeFalse(); + } + + [Fact] + public void Import_RejectsReparsePointsInDestinationBeforeArchiveExtraction() + { + using var directory = new TestDirectory(); + string sourceFilePath = directory.CreateFile("source/package.zip", "archive content"); + string ownedRoot = directory.CreateDirectory("owned"); + string destinationDirectory = directory.CreateDirectory("owned/Mod/1.0"); + string externalTarget = directory.CreateDirectory("external"); + string externalFile = directory.CreateFile("external/target.txt", "target"); + ReparsePointTestSupport.CreateDirectoryJunction( + Path.Combine(destinationDirectory, "linked"), + externalTarget); + RecordingArchiveExtractor archiveExtractor = new(); + FileSystemManualModificationImporter importer = CreateImporter(archiveExtractor); + + Action act = () => importer.Import( + new[] { sourceFilePath }, + CreateOwnedDestination(ownedRoot, destinationDirectory)); + + act.Should().Throw() + .WithMessage("*reparse point*"); + archiveExtractor.ArchiveFilePath.Should().BeNull(); + File.Exists(Path.Combine(destinationDirectory, "package.zip")).Should().BeFalse(); + File.ReadAllText(externalFile).Should().Be("target"); + } + + [Fact] + public void Import_RethrowsWhenSourceFileIsMissing() + { + 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); + + Action act = () => importer.Import( + new[] { missingSourceFilePath }, + CreateOwnedDestination(directory.Path, destinationDirectory)); + + act.Should().Throw(); + logger.Entries.Should().Contain(entry => + entry.LogLevel == LogLevel.Error && + entry.Exception is FileNotFoundException); + } + + private static void WriteExtractedFile(string destinationDirectory) + { + File.WriteAllText(Path.Combine(destinationDirectory, "extracted.txt"), "extracted content"); + } + + private static OwnedContentPath CreateOwnedDestination( + string ownedRoot, + string destinationDirectory) + { + return new OwnedContentPath(ownedRoot, destinationDirectory); + } + + private static FileSystemManualModificationImporter CreateImporter( + IArchiveExtractor? archiveExtractor = null, + ILogger? logger = null) + { + return new FileSystemManualModificationImporter( + archiveExtractor ?? new RecordingArchiveExtractor(), + logger ?? NullLogger.Instance); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemModificationImageFileServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemModificationImageFileServiceTests.cs new file mode 100644 index 00000000..a0541a96 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemModificationImageFileServiceTests.cs @@ -0,0 +1,549 @@ +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 Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class FileSystemModificationImageFileServiceTests +{ + [Fact] + public void FindExistingImageFilePath_FindsCachedImageWhateverItsExtension() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string imagePath = Path.Combine(imageDirectory, "1.2.jpg"); + File.WriteAllText(imagePath, "image"); + File.WriteAllText(Path.Combine(imageDirectory, "2.0.png"), "other image"); + FileSystemModificationImageFileService service = CreateService(paths); + + string? existingImagePath = service.FindExistingImageFilePath(ModificationType.Mod, "ShockWave", "1.2"); + + existingImagePath.Should().Be(imagePath); + } + + /// + /// Characterizes today's behaviour, which is not the behaviour anyone wants: the cache lookup builds a + /// <version>.* wildcard, so version "1.2" also matches the artwork cached for "1.2.5". Revisit + /// with PR-11, which replaces the wildcard with an exact base-name match. + /// + [Fact] + public void FindExistingImageFilePath_MatchesTheImageOfAVersionThatOnlyExtendsTheRequestedName() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string siblingImagePath = Path.Combine(imageDirectory, "1.2.5.png"); + File.WriteAllText(siblingImagePath, "sibling"); + FileSystemModificationImageFileService service = CreateService(paths); + + string? existingImagePath = service.FindExistingImageFilePath(ModificationType.Mod, "ShockWave", "1.2"); + + existingImagePath.Should().Be(siblingImagePath); + } + + /// + /// Characterizes today's behaviour: the same <version>.* wildcard makes removing "1.2" take + /// "1.2.5" with it. Revisit with PR-11. + /// + [Fact] + public void TryDeleteImage_AlsoRemovesTheImageOfAVersionThatOnlyExtendsTheRequestedName() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string requestedImagePath = Path.Combine(imageDirectory, "1.2.png"); + string siblingImagePath = Path.Combine(imageDirectory, "1.2.5.png"); + File.WriteAllText(requestedImagePath, "requested"); + File.WriteAllText(siblingImagePath, "sibling"); + FileSystemModificationImageFileService service = CreateService(paths); + + bool deleted = service.TryDeleteImage(ModificationType.Mod, "ShockWave", "1.2"); + + deleted.Should().BeTrue(); + File.Exists(requestedImagePath).Should().BeFalse(); + File.Exists(siblingImagePath).Should().BeFalse(); + } + + /// + /// The wildcard is anchored on the whole requested name, so the over-match only ever runs one way: removing + /// "1.2" may take "1.2.5" with it, but it must never take the artwork of "1", which is a different version the + /// user still has installed. + /// + [Fact] + public void TryDeleteImage_KeepsTheImageOfTheVersionWhoseNameTheRequestedNameExtends() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string requestedImagePath = Path.Combine(imageDirectory, "1.2.png"); + string shorterVersionImagePath = Path.Combine(imageDirectory, "1.png"); + File.WriteAllText(requestedImagePath, "requested"); + File.WriteAllText(shorterVersionImagePath, "shorter"); + FileSystemModificationImageFileService service = CreateService(paths); + + bool deleted = service.TryDeleteImage(ModificationType.Mod, "ShockWave", "1.2"); + + deleted.Should().BeTrue(); + File.Exists(requestedImagePath).Should().BeFalse(); + File.ReadAllText(shorterVersionImagePath).Should().Be("shorter"); + } + + [Fact] + public void FindExistingImageFilePath_ReturnsNullWhenNoCachedImageMatches() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + File.WriteAllText(Path.Combine(imageDirectory, "1.2.png"), "image"); + FileSystemModificationImageFileService service = CreateService(paths); + + string? existingImagePath = service.FindExistingImageFilePath(ModificationType.Mod, "ShockWave", "3.0"); + + existingImagePath.Should().BeNull(); + } + + /// + /// Characterizes today's behaviour: the stale-sibling sweep before a replacement uses the same + /// <version>.* wildcard, so replacing "1.2" also discards the artwork of "1.2.5". Revisit with + /// PR-11. + /// + [Fact] + public async Task ReplaceImageAsync_AlsoRemovesTheImageOfAVersionThatOnlyExtendsTheRequestedNameAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string siblingImagePath = Path.Combine(imageDirectory, "1.2.5.png"); + await File.WriteAllTextAsync(siblingImagePath, "sibling", TestContext.Current.CancellationToken); + string sourceImagePath = directory.CreateFile("selected.png", "new"); + FileSystemModificationImageFileService service = CreateService(paths); + + string destinationPath = await service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", sourceImagePath), + CancellationToken.None); + + destinationPath.Should().Be(Path.Combine(imageDirectory, "1.2.png")); + File.Exists(siblingImagePath).Should().BeFalse(); + (await File.ReadAllTextAsync(destinationPath, TestContext.Current.CancellationToken)).Should().Be("new"); + } + + [Fact] + public void FindExistingImageFilePath_ReturnsNullForMissingDirectory() + { + using TestDirectory directory = new(); + FileSystemModificationImageFileService service = CreateService(TestLauncherPaths.Create(directory.Path)); + + string? existingImagePath = service.FindExistingImageFilePath(ModificationType.Mod, "Missing", "1.2"); + + existingImagePath.Should().BeNull(); + } + + [Fact] + public void CountImageFiles_ReturnsZeroForMissingDirectory() + { + using TestDirectory directory = new(); + FileSystemModificationImageFileService service = CreateService(TestLauncherPaths.Create(directory.Path)); + + int count = service.CountImageFiles(ModificationType.Mod, "Missing"); + + count.Should().Be(0); + } + + [Fact] + public void CountImageFiles_ReturnsImageFileCount() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(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); + + int count = service.CountImageFiles(ModificationType.Mod, "ShockWave"); + + count.Should().Be(2); + } + + [Fact] + public void CountImageFiles_AdvertisingUsesTheCatalogCacheName() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("Do you like GenLauncher"); + Directory.CreateDirectory(imageDirectory); + File.WriteAllText(Path.Combine(imageDirectory, "0.jpg"), "image"); + FileSystemModificationImageFileService service = CreateService(paths); + + int count = service.CountImageFiles( + ModificationType.Advertising, + "Do you like GenLauncher?"); + + count.Should().Be(1); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void ImageExists_ReturnsFalseForMissingPathValues(string? imagePath) + { + using TestDirectory directory = new(); + FileSystemModificationImageFileService service = CreateService(TestLauncherPaths.Create(directory.Path)); + + bool exists = service.ImageExists(imagePath); + + exists.Should().BeFalse(); + } + + [Fact] + public void ImageExists_ReturnsTrueForExistingFile() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + string imagePath = paths.GetModificationImageFilePath("ShockWave", "1.2.png"); + Directory.CreateDirectory(Path.GetDirectoryName(imagePath)!); + File.WriteAllText(imagePath, "image"); + FileSystemModificationImageFileService service = CreateService(paths); + + bool exists = service.ImageExists(imagePath); + + exists.Should().BeTrue(); + } + + [Fact] + public void ImageExists_ReturnsFalseForExistingFileOutsideActiveCache() + { + using TestDirectory directory = new(); + string imagePath = Path.Combine(directory.Path, "image.png"); + File.WriteAllText(imagePath, "image"); + FileSystemModificationImageFileService service = CreateService(TestLauncherPaths.Create(directory.Path)); + + bool exists = service.ImageExists(imagePath); + + exists.Should().BeFalse(); + File.Exists(imagePath).Should().BeTrue(); + } + + [Fact] + public void TryDeleteImage_RemovesMatchingCachedImages() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string pngImagePath = Path.Combine(imageDirectory, "1.2.png"); + string jpgImagePath = Path.Combine(imageDirectory, "1.2.jpg"); + File.WriteAllText(pngImagePath, "png"); + File.WriteAllText(jpgImagePath, "jpg"); + FileSystemModificationImageFileService service = CreateService(paths); + + bool deleted = service.TryDeleteImage(ModificationType.Mod, "ShockWave", "1.2"); + + deleted.Should().BeTrue(); + File.Exists(pngImagePath).Should().BeFalse(); + File.Exists(jpgImagePath).Should().BeFalse(); + } + + [Fact] + public void TryDeleteImage_RejectsUnsafeCacheIdentity() + { + using TestDirectory directory = new(); + string outsideImagePath = directory.CreateFile("victim.png", "outside"); + FileSystemModificationImageFileService service = CreateService(TestLauncherPaths.Create(directory.Path)); + + bool deleted = service.TryDeleteImage(ModificationType.Mod, "..", "victim"); + + deleted.Should().BeFalse(); + File.ReadAllText(outsideImagePath).Should().Be("outside"); + } + + [Fact] + public void TryDeleteImage_ReturnsTrueWhenFileDoesNotExist() + { + using TestDirectory directory = new(); + FileSystemModificationImageFileService service = CreateService(TestLauncherPaths.Create(directory.Path)); + + bool deleted = service.TryDeleteImage(ModificationType.Mod, "ShockWave", "missing"); + + deleted.Should().BeTrue(); + } + + [Fact] + public void FindExistingImageFilePath_RejectsLinkedImageDirectory() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string outsideDirectory = directory.CreateDirectory("OutsideImages"); + string outsideImagePath = Path.Combine(outsideDirectory, "1.2.png"); + File.WriteAllText(outsideImagePath, "outside"); + ReparsePointTestSupport.CreateDirectoryJunction( + paths.GetModificationImagesDirectory("ShockWave"), + outsideDirectory); + FileSystemModificationImageFileService service = CreateService(paths); + + Action act = () => service.FindExistingImageFilePath(ModificationType.Mod, "ShockWave", "1.2"); + + act.Should().Throw(); + File.ReadAllText(outsideImagePath).Should().Be("outside"); + } + + [Fact] + public void TryDeleteImage_DoesNotFollowLinkedImageDirectory() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string outsideDirectory = directory.CreateDirectory("OutsideImages"); + string outsideImagePath = Path.Combine(outsideDirectory, "1.2.png"); + File.WriteAllText(outsideImagePath, "outside"); + ReparsePointTestSupport.CreateDirectoryJunction( + paths.GetModificationImagesDirectory("ShockWave"), + outsideDirectory); + FileSystemModificationImageFileService service = CreateService(paths); + + bool deleted = service.TryDeleteImage(ModificationType.Mod, "ShockWave", "1.2"); + + deleted.Should().BeFalse(); + File.ReadAllText(outsideImagePath).Should().Be("outside"); + } + + [Fact] + public async Task ReplaceImageAsync_DoesNotFollowLinkedImageDirectoryAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string outsideDirectory = directory.CreateDirectory("OutsideImages"); + string outsideImagePath = Path.Combine(outsideDirectory, "1.2.jpg"); + await File.WriteAllTextAsync(outsideImagePath, "outside", TestContext.Current.CancellationToken); + ReparsePointTestSupport.CreateDirectoryJunction( + paths.GetModificationImagesDirectory("ShockWave"), + outsideDirectory); + string sourceImagePath = directory.CreateFile("selected.png", "new"); + FileSystemModificationImageFileService service = CreateService(paths); + + Func act = () => service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", sourceImagePath), + CancellationToken.None); + + await act.Should().ThrowAsync(); + (await File.ReadAllTextAsync(outsideImagePath, TestContext.Current.CancellationToken)).Should().Be("outside"); + File.Exists(Path.Combine(outsideDirectory, "1.2.png")).Should().BeFalse(); + } + + [Fact] + public async Task ReplaceImageAsync_DeletesStaleExtensionsAndCopiesSelectedImageAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string staleImagePath = Path.Combine(imageDirectory, "1.2.jpg"); + await File.WriteAllTextAsync(staleImagePath, "old", TestContext.Current.CancellationToken); + string sourceImagePath = Path.Combine(directory.Path, "selected.png"); + await File.WriteAllTextAsync(sourceImagePath, "new", TestContext.Current.CancellationToken); + FileSystemModificationImageFileService service = CreateService(paths); + + string destinationPath = await service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", sourceImagePath), + CancellationToken.None); + + destinationPath.Should().Be(Path.Combine(imageDirectory, "1.2.png")); + File.Exists(staleImagePath).Should().BeFalse(); + (await File.ReadAllTextAsync(destinationPath, TestContext.Current.CancellationToken)).Should().Be("new"); + } + + [Fact] + public async Task ReplaceImageAsyncNoOpsWhenSourceAlready_IsDestinationAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string existingImagePath = Path.Combine(imageDirectory, "1.2.png"); + await File.WriteAllTextAsync(existingImagePath, "same", TestContext.Current.CancellationToken); + FileSystemModificationImageFileService service = CreateService(paths); + + string destinationPath = await service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", existingImagePath), + CancellationToken.None); + + destinationPath.Should().Be(existingImagePath); + (await File.ReadAllTextAsync(existingImagePath, TestContext.Current.CancellationToken)).Should().Be("same"); + } + + [Fact] + public async Task ReplaceImageAsync_ThrowsForSourceWithoutExtensionAsync() + { + using TestDirectory directory = new(); + string sourceImagePath = Path.Combine(directory.Path, "selected"); + await File.WriteAllTextAsync(sourceImagePath, "new", TestContext.Current.CancellationToken); + FileSystemModificationImageFileService service = CreateService(TestLauncherPaths.Create(directory.Path)); + + Func act = () => service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", sourceImagePath), + CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + // ReSharper disable once InconsistentNaming + [Fact] + public async Task ReplaceImageAsync_ThrowsIOExceptionWhenSourceCannotBeCopiedAsync() + { + using TestDirectory directory = new(); + string sourceImagePath = Path.Combine(directory.Path, "missing.png"); + FileSystemModificationImageFileService service = CreateService(TestLauncherPaths.Create(directory.Path)); + + Func act = () => service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", sourceImagePath), + CancellationToken.None); + + (await act.Should().ThrowAsync() + .WithMessage("Could not replace cached image '1.2' for modification 'ShockWave'.")) + .Which.InnerException.Should().BeOfType(); + } + + [Fact] + public async Task ReplaceImageAsync_HonorsPreCanceledTokenAsync() + { + using TestDirectory directory = new(); + string sourceImagePath = Path.Combine(directory.Path, "selected.png"); + await File.WriteAllTextAsync(sourceImagePath, "new", TestContext.Current.CancellationToken); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + FileSystemModificationImageFileService service = CreateService(TestLauncherPaths.Create(directory.Path)); + + Func act = () => service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", sourceImagePath), + cancellation.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ReplaceImageAsync_UsesNewGameCacheWithoutRebuildingServiceAsync() + { + using TestDirectory directory = new(); + (LauncherRuntimePathContext runtimePaths, LauncherPaths generalsPaths, LauncherPaths zeroHourPaths) = + TestLauncherPaths.CreateTwoGameRuntime(directory); + var service = new FileSystemModificationImageFileService( + runtimePaths, + NullLogger.Instance); + string sourceImagePath = directory.CreateFile("selected.png", "image"); + var request = new ModificationImageReplacementRequest("Shared Mod", "1.0", sourceImagePath); + + string zeroHourImage = await service.ReplaceImageAsync(request, CancellationToken.None); + runtimePaths.SwitchActive(generalsPaths); + string generalsImage = await service.ReplaceImageAsync(request, CancellationToken.None); + + generalsImage.Should().StartWith(generalsPaths.ImagesDirectory); + zeroHourImage.Should().StartWith(zeroHourPaths.ImagesDirectory); + File.Exists(generalsImage).Should().BeTrue(); + File.Exists(zeroHourImage).Should().BeTrue(); + } + + /// + /// Ownership of a cache folder is not settled by the folder alone: one link below it leads somewhere the + /// launcher never created, so the lookup refuses the folder rather than reporting a path from it. + /// + [Fact] + public void FindExistingImageFilePath_RejectsImageDirectoryContainingALink() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + File.WriteAllText(Path.Combine(imageDirectory, "1.2.png"), "image"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + Path.Combine(imageDirectory, "Linked")); + FileSystemModificationImageFileService service = CreateService(paths); + + Action act = () => service.FindExistingImageFilePath(ModificationType.Mod, "ShockWave", "1.2"); + + act.Should().Throw(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void CountImageFiles_RejectsImageDirectoryContainingALink() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + File.WriteAllText(Path.Combine(imageDirectory, "1.2.png"), "image"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + Path.Combine(imageDirectory, "Linked")); + FileSystemModificationImageFileService service = CreateService(paths); + + Action act = () => service.CountImageFiles(ModificationType.Mod, "ShockWave"); + + act.Should().Throw(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void TryDeleteImage_KeepsCachedImagesWhenTheImageDirectoryContainsALink() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + string imagePath = Path.Combine(imageDirectory, "1.2.png"); + File.WriteAllText(imagePath, "image"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + Path.Combine(imageDirectory, "Linked")); + FileSystemModificationImageFileService service = CreateService(paths); + + bool deleted = service.TryDeleteImage(ModificationType.Mod, "ShockWave", "1.2"); + + deleted.Should().BeFalse(); + File.ReadAllText(imagePath).Should().Be("image"); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public async Task ReplaceImageAsync_WritesNothingWhenTheImageDirectoryContainsALinkAsync() + { + using TestDirectory directory = new(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string imageDirectory = paths.GetModificationImagesDirectory("ShockWave"); + Directory.CreateDirectory(imageDirectory); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + Path.Combine(imageDirectory, "Linked")); + string sourceImagePath = directory.CreateFile("selected.png", "new"); + FileSystemModificationImageFileService service = CreateService(paths); + + Func act = () => service.ReplaceImageAsync( + new ModificationImageReplacementRequest("ShockWave", "1.2", sourceImagePath), + CancellationToken.None); + + await act.Should().ThrowAsync(); + File.Exists(Path.Combine(imageDirectory, "1.2.png")).Should().BeFalse(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + private static FileSystemModificationImageFileService CreateService(LauncherPaths paths) + { + return new FileSystemModificationImageFileService( + TestLauncherPaths.CreateRuntimePathContext(paths), + NullLogger.Instance); + } + +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemModificationThemeCacheTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemModificationThemeCacheTests.cs new file mode 100644 index 00000000..161c85f4 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/FileSystemModificationThemeCacheTests.cs @@ -0,0 +1,143 @@ +using System.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Infrastructure.Persistence.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class FileSystemModificationThemeCacheTests +{ + [Fact] + public void CachedPalette_SurvivesAReadBack() + { + using TestDirectory directory = new(); + FileSystemModificationThemeCache cache = CreateCache(directory, out _); + LauncherContentTheme theme = new() + { + GenLauncherBorderColor = "#00e3ff", + GenLauncherInactiveBorder = "DarkGray", + GenLauncherActiveColor = "#baff0c", + GenLauncherBackgroundImageLink = "https://cdn.example.test/contra.png" + }; + + LauncherContentKey contentKey = CreateKey("Contra", "1.0"); + cache.Save(contentKey, theme); + + LauncherContentTheme? loaded = cache.Load(contentKey); + loaded.Should().NotBeNull(); + loaded!.GenLauncherBorderColor.Should().Be("#00e3ff"); + loaded.GenLauncherInactiveBorder.Should().Be("DarkGray"); + loaded.GenLauncherActiveColor.Should().Be("#baff0c"); + loaded.GenLauncherBackgroundImageLink.Should().Be("https://cdn.example.test/contra.png"); + loaded.GenLauncherDarkFillColor.Should().BeEmpty(); + } + + [Fact] + public void UncachedPalette_LoadsAsNothing() + { + using TestDirectory directory = new(); + FileSystemModificationThemeCache cache = CreateCache(directory, out _); + + cache.Load(CreateKey("Contra", "1.0")).Should().BeNull(); + } + + /// + /// The palette is cached inside the modification's own image folder so that removing the content card, which + /// deletes that folder, takes the palette with it rather than leaving an orphan behind. It is written as a + /// YAML document under the shared palette cache name, which is the name a later start looks for: a different + /// name orphans every palette already cached on disk instead of reading it back. + /// + [Fact] + public void CachedPalette_LivesInTheModificationImageCacheFolder() + { + using TestDirectory directory = new(); + FileSystemModificationThemeCache cache = CreateCache(directory, out LauncherPaths paths); + + cache.Save( + CreateKey("Contra", "1.0"), + new LauncherContentTheme { GenLauncherActiveColor = "#baff0c" }); + + string imageFolder = paths.GetModificationImagesDirectory("Contra"); + Directory.EnumerateFiles(imageFolder).Should().ContainSingle() + .Which.Should().Be(Path.Combine( + imageFolder, + LauncherContentTheme.ResolveCacheBaseName("1.0") + ".yaml")); + } + + /// + /// Content with no usable identity has no cache folder of its own, so the palette is dropped rather than + /// written somewhere another modification would later read it back from. + /// + [Theory] + [InlineData("", "1.0")] + [InlineData("Contra", " ")] + public void Save_KeepsNothingForBlankContentIdentity(string modificationName, string version) + { + using TestDirectory directory = new(); + FileSystemModificationThemeCache cache = CreateCache(directory, out LauncherPaths paths); + + LauncherContentKey contentKey = CreateKey(modificationName, version); + cache.Save(contentKey, new LauncherContentTheme { GenLauncherActiveColor = "#baff0c" }); + + cache.Load(contentKey).Should().BeNull(); + Directory.EnumerateFileSystemEntries(paths.ImagesDirectory).Should().BeEmpty(); + } + + [Fact] + public void Save_KeepsNothingWhenTheImageFolderIsALink() + { + using TestDirectory directory = new(); + FileSystemModificationThemeCache cache = CreateCache(directory, out LauncherPaths paths); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + paths.GetModificationImagesDirectory("Contra")); + + LauncherContentKey contentKey = CreateKey("Contra", "1.0"); + cache.Save(contentKey, new LauncherContentTheme { GenLauncherActiveColor = "#baff0c" }); + + cache.Load(contentKey).Should().BeNull(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + Directory.EnumerateFileSystemEntries(junction.TargetDirectory).Should().ContainSingle(); + } + + /// + /// A palette that cannot be read is absent, not empty: handing back an all-blank palette would re-skin the + /// shell with nothing instead of leaving the active game's own colours in place. + /// + [Theory] + [InlineData("")] + [InlineData("GenLauncherActiveColor: [unterminated")] + public void Load_ReturnsNothingForAnUnreadableCachedDocument(string cachedDocument) + { + using TestDirectory directory = new(); + FileSystemModificationThemeCache cache = CreateCache(directory, out LauncherPaths paths); + string documentPath = paths.GetModificationImageFilePath( + "Contra", + LauncherContentTheme.ResolveCacheBaseName("1.0") + ".yaml"); + Directory.CreateDirectory(Path.GetDirectoryName(documentPath)!); + File.WriteAllText(documentPath, cachedDocument); + + LauncherContentTheme? loaded = cache.Load(CreateKey("Contra", "1.0")); + + loaded.Should().BeNull(); + } + + private static FileSystemModificationThemeCache CreateCache( + TestDirectory directory, + out LauncherPaths paths) + { + paths = TestLauncherPaths.Create(directory); + return new FileSystemModificationThemeCache( + TestLauncherPaths.CreateRuntimePathContext(paths), + new AtomicFileWriter(), + NullLogger>.Instance, + NullLogger.Instance); + } + + private static LauncherContentKey CreateKey(string name, string version) + { + return new LauncherContentKey(ModificationType.Mod, string.Empty, name, version); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherCatalogImageCacheTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherCatalogImageCacheTests.cs new file mode 100644 index 00000000..05f1361f --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherCatalogImageCacheTests.cs @@ -0,0 +1,387 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class LauncherCatalogImageCacheTests +{ + [Theory] + [InlineData("https://cdn.example.test/card.jpeg", "1.2.jpeg")] + [InlineData("https://cdn.example.test/card.webp", "1.2.png")] + [InlineData("https://cdn.example.test/card", "1.2.png")] + public async Task CacheModificationImagesAsync_DownloadsCardImageToExpectedPathAsync( + string cardLink, + string expectedImageFileName) + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherCatalogImageCache cache = CreateCache(assetDownloader); + var cardUri = new Uri(cardLink); + var modification = new LauncherContentVersion + { + Name = "ShockWave", + Version = "1.2", + UIImageSourceLink = cardUri.ToString() + }; + + await cache.CacheModificationImagesAsync(modification, paths, CancellationToken.None); + + assetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == cardUri && + call.DestinationFilePath == paths.GetModificationImageFilePath("ShockWave", expectedImageFileName)); + } + + [Fact] + public async Task CacheModificationImagesAsync_SkipsEmptyImageLinksAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherCatalogImageCache cache = CreateCache(assetDownloader); + var modification = new LauncherContentVersion + { + Name = "ShockWave", + Version = "1.2", + UIImageSourceLink = string.Empty + }; + + await cache.CacheModificationImagesAsync(modification, paths, CancellationToken.None); + + assetDownloader.Calls.Should().BeEmpty(); + } + + /// + /// A themed modification re-skins the shell the moment it is picked, and still does so after an offline + /// restart, which only works if both halves of the theme are cached ahead of selection. + /// + [Fact] + public async Task CacheModificationImagesAsync_CachesPublishedPaletteAndBackgroundArtworkAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var assetDownloader = new RecordingRemoteAssetDownloader(); + var themeCache = new FakeModificationThemeCache(); + LauncherCatalogImageCache cache = CreateCache(assetDownloader, themeCache); + var backgroundUri = new Uri("https://cdn.example.test/background.jpg"); + var theme = new LauncherContentTheme + { + GenLauncherActiveColor = "#baff0c", + GenLauncherBackgroundImageLink = backgroundUri.ToString() + }; + var modification = new LauncherContentVersion + { + Name = "ShockWave", + Version = "1.2", + UIImageSourceLink = "https://cdn.example.test/card.png", + Theme = theme + }; + + await cache.CacheModificationImagesAsync(modification, paths, CancellationToken.None); + + themeCache.Entries[modification.ContentKey].Should().BeSameAs(theme); + assetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == backgroundUri && + call.DestinationFilePath == paths.GetModificationImageFilePath( + "ShockWave", + LauncherContentTheme.ResolveBackgroundImageBaseName("1.2") + ".jpg")); + } + + [Fact] + public async Task CacheModificationImagesAsync_CachesPublishedPaletteThatDeclaresNoBackgroundArtworkAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var assetDownloader = new RecordingRemoteAssetDownloader(); + var themeCache = new FakeModificationThemeCache(); + LauncherCatalogImageCache cache = CreateCache(assetDownloader, themeCache); + var cardUri = new Uri("https://cdn.example.test/card.png"); + var theme = new LauncherContentTheme { GenLauncherActiveColor = "#baff0c" }; + var modification = new LauncherContentVersion + { + Name = "ShockWave", + Version = "1.2", + UIImageSourceLink = cardUri.ToString(), + Theme = theme + }; + + await cache.CacheModificationImagesAsync(modification, paths, CancellationToken.None); + + themeCache.Entries[modification.ContentKey].Should().BeSameAs(theme); + assetDownloader.Calls.Should().Equal( + (cardUri, paths.GetModificationImageFilePath("ShockWave", "1.2.png"))); + } + + [Fact] + public async Task CacheModificationImagesAsync_ContinuesWhenCardImageDownloadFailsAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherCatalogImageCache cache = CreateCache(assetDownloader); + var cardUri = new Uri("https://cdn.example.test/card.png"); + var modification = new LauncherContentVersion + { + Name = "ShockWave", + Version = "1.2", + UIImageSourceLink = cardUri.ToString() + }; + assetDownloader.Handler = (_, _, _) => Task.FromException(new IOException("Download failed.")); + + await cache.CacheModificationImagesAsync(modification, paths, CancellationToken.None); + + assetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == cardUri && + call.DestinationFilePath == paths.GetModificationImageFilePath("ShockWave", "1.2.png")); + } + + [Fact] + public async Task CacheModificationImagesAsync_RethrowsCancellationAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherCatalogImageCache cache = CreateCache(assetDownloader); + var cancellationTokenSource = new CancellationTokenSource(); + var imageUri = new Uri("https://cdn.example.test/card.png"); + var modification = new LauncherContentVersion + { + Name = "ShockWave", + Version = "1.2", + UIImageSourceLink = imageUri.ToString() + }; + cancellationTokenSource.Cancel(); + assetDownloader.Handler = (_, _, _) => Task.FromCanceled(cancellationTokenSource.Token); + + Func act = () => cache.CacheModificationImagesAsync( + modification, + paths, + cancellationTokenSource.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task CacheModificationImagesAsync_DoesNotDownloadThroughLinkedImageDirectoryAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string outsideDirectory = directory.CreateDirectory("OutsideImages"); + ReparsePointTestSupport.CreateDirectoryJunction( + paths.GetModificationImagesDirectory("ShockWave"), + outsideDirectory); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherCatalogImageCache cache = CreateCache(assetDownloader); + var modification = new LauncherContentVersion + { + Name = "ShockWave", + Version = "1.2", + UIImageSourceLink = "https://cdn.example.test/card.png" + }; + + await cache.CacheModificationImagesAsync(modification, paths, CancellationToken.None); + + assetDownloader.Calls.Should().BeEmpty(); + Directory.EnumerateFileSystemEntries(outsideDirectory).Should().BeEmpty(); + } + + [Fact] + public async Task CacheAdvertisingImagesAsync_DeletesStaleImagesWhenImageCountChangesAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + Directory.CreateDirectory(paths.GetModificationImagesDirectory("Featured Mod")); + string staleImagePath = paths.GetModificationImageFilePath("Featured Mod", "old.png"); + await File.WriteAllTextAsync(staleImagePath, "stale", TestContext.Current.CancellationToken); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherCatalogImageCache cache = CreateCache(assetDownloader); + 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" + }); + + await cache.CacheAdvertisingImagesAsync(advertisingData, paths, CancellationToken.None); + + File.Exists(staleImagePath).Should().BeFalse(); + assetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == new Uri("https://cdn.example.test/0.jpg") && + call.DestinationFilePath == paths.GetModificationImageFilePath("Featured Mod", "0.jpg")); + assetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == new Uri("https://cdn.example.test/1.jpg") && + call.DestinationFilePath == paths.GetModificationImageFilePath("Featured Mod", "1.jpg")); + } + + [Fact] + public async Task CacheAdvertisingImagesAsync_TrimsInvalidTrailingNameCharactersLikeLegacyCacheAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherCatalogImageCache cache = CreateCache(assetDownloader); + var advertisingData = new RemoteAdvertisingReference( + "Do you like GenLauncher?", + "https://example.test/advertising.yaml", + new[] { "https://cdn.example.test/0.jpg" }); + + await cache.CacheAdvertisingImagesAsync(advertisingData, paths, CancellationToken.None); + + assetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == new Uri("https://cdn.example.test/0.jpg") && + call.DestinationFilePath == paths.GetModificationImageFilePath("Do you like GenLauncher", "0.jpg")); + } + + [Fact] + public async Task CacheAdvertisingImagesAsync_ContinuesWhenStaleImageCannotBeDeletedAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + Directory.CreateDirectory(paths.GetModificationImagesDirectory("Featured Mod")); + string staleImagePath = paths.GetModificationImageFilePath("Featured Mod", "old.png"); + await File.WriteAllTextAsync(staleImagePath, "stale", TestContext.Current.CancellationToken); + await using FileStream lockedImage = File.Open( + staleImagePath, + FileMode.Open, + FileAccess.Read, + FileShare.None); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherCatalogImageCache cache = CreateCache(assetDownloader); + 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" + }); + + Func act = () => cache.CacheAdvertisingImagesAsync( + advertisingData, + paths, + CancellationToken.None); + + await act.Should().NotThrowAsync(); + assetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == new Uri("https://cdn.example.test/0.jpg") && + call.DestinationFilePath == paths.GetModificationImageFilePath("Featured Mod", "0.jpg")); + } + + [Fact] + public async Task CacheAdvertisingImagesAsync_KeepsExistingImagesWhenImageCountMatchesAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(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", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(secondExistingImagePath, "existing", TestContext.Current.CancellationToken); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherCatalogImageCache cache = CreateCache(assetDownloader); + 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" + }); + + await cache.CacheAdvertisingImagesAsync(advertisingData, paths, CancellationToken.None); + + File.Exists(firstExistingImagePath).Should().BeTrue(); + File.Exists(secondExistingImagePath).Should().BeTrue(); + assetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == new Uri("https://cdn.example.test/0.png") && + call.DestinationFilePath == paths.GetModificationImageFilePath("Featured Mod", "0.png")); + assetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == new Uri("https://cdn.example.test/1.png") && + call.DestinationFilePath == paths.GetModificationImageFilePath("Featured Mod", "1.png")); + } + + [Fact] + public async Task CacheAdvertisingImagesAsync_DoesNotMutateThroughLinkedImageDirectoryAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string outsideDirectory = directory.CreateDirectory("OutsideImages"); + string firstOutsideImage = Path.Combine(outsideDirectory, "old-1.png"); + string secondOutsideImage = Path.Combine(outsideDirectory, "old-2.png"); + await File.WriteAllTextAsync(firstOutsideImage, "first", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(secondOutsideImage, "second", TestContext.Current.CancellationToken); + ReparsePointTestSupport.CreateDirectoryJunction( + paths.GetModificationImagesDirectory("Featured Mod"), + outsideDirectory); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherCatalogImageCache cache = CreateCache(assetDownloader); + var advertisingData = new RemoteAdvertisingReference( + "Featured Mod", + "https://example.test/featured.yaml", + new List + { + "https://cdn.example.test/0.jpg" + }); + + await cache.CacheAdvertisingImagesAsync(advertisingData, paths, CancellationToken.None); + + assetDownloader.Calls.Should().BeEmpty(); + (await File.ReadAllTextAsync(firstOutsideImage, TestContext.Current.CancellationToken)).Should().Be("first"); + (await File.ReadAllTextAsync(secondOutsideImage, TestContext.Current.CancellationToken)).Should().Be("second"); + } + + /// + /// A cache folder is only fully owned when nothing inside it leads out of the launcher tree. One link below it + /// is enough to stop both halves of the refresh: the stale-image sweep, which deletes files the launcher would + /// no longer be able to account for, and the download that would write into that same folder. + /// + [Fact] + public async Task CacheAdvertisingImagesAsync_KeepsCachedImagesWhenTheCacheFolderContainsALinkAsync() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string imageFolder = paths.GetModificationImagesDirectory("Featured Mod"); + Directory.CreateDirectory(imageFolder); + string staleImagePath = Path.Combine(imageFolder, "old.png"); + await File.WriteAllTextAsync(staleImagePath, "stale", TestContext.Current.CancellationToken); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + Path.Combine(imageFolder, "Linked")); + var assetDownloader = new RecordingRemoteAssetDownloader(); + LauncherCatalogImageCache cache = CreateCache(assetDownloader); + 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" + }); + + await cache.CacheAdvertisingImagesAsync(advertisingData, paths, CancellationToken.None); + + File.Exists(staleImagePath).Should().BeTrue(); + assetDownloader.Calls.Should().BeEmpty(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + private static LauncherCatalogImageCache CreateCache( + RecordingRemoteAssetDownloader assetDownloader, + FakeModificationThemeCache? themeCache = null) + { + return new LauncherCatalogImageCache( + assetDownloader, + themeCache ?? new FakeModificationThemeCache(), + NullLogger.Instance); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentCatalogServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentCatalogServiceTests.cs new file mode 100644 index 00000000..646f061f --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentCatalogServiceTests.cs @@ -0,0 +1,1243 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Exceptions; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class LauncherContentCatalogServiceTests +{ + [Fact] + public async Task InitDataAsyncWithDisconnectedCatalog_LoadsOnlyLocalStateAsync() + { + using var harness = new CatalogTestHarness(); + harness.StateStore.StateToLoad = CreateState( + TestLauncherContent.Version("ShockWave", "1.0", installed: true)); + harness.LocalContent.InstalledVersions = + [ + TestLauncherContent.Version("ShockWave", "1.0", installed: true) + ]; + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + await harness.Service.ReadPatchesAndAddonsForModAsync( + LauncherContentKey.ForModificationName("ShockWave"), + CancellationToken.None); + + harness.Service.Data.Modifications.Select(modification => modification.Name).Should().Equal("ShockWave"); + harness.Service.RepositoryModificationNames.Should().BeNull(); + harness.YamlReader.GetReadCount().Should().Be(0); + } + + [Fact] + public async Task InitDataAsyncSwitchesGameNamespaceAnd_ClearsPreviouslyCachedContentAsync() + { + using var harness = new CatalogTestHarness(); + (LauncherPaths generalsPaths, LauncherPaths zeroHourPaths) = harness.CreateBothGamePaths(); + harness.StateStore.StatesToLoadByGame[SupportedGame.Generals] = + CreateState(TestLauncherContent.Version("Shared Mod", "Generals Version", installed: true)); + harness.StateStore.StatesToLoadByGame[SupportedGame.ZeroHour] = + CreateState(TestLauncherContent.Version("Shared Mod", "Zero Hour Version", installed: true)); + harness.LocalContent.InstalledVersions = + [ + TestLauncherContent.Version("Shared Mod", "Generals Version", installed: true) + ]; + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, generalsPaths), + CancellationToken.None); + harness.Service.Data.Modifications.Should().ContainSingle() + .Which.Versions.Should().ContainSingle() + .Which.Version.Should().Be("Generals Version"); + + harness.LocalContent.InstalledVersions = + [ + TestLauncherContent.Version("Shared Mod", "Zero Hour Version", installed: true) + ]; + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, zeroHourPaths), + CancellationToken.None); + + harness.Service.Data.Modifications.Should().ContainSingle() + .Which.Versions.Should().ContainSingle() + .Which.Version.Should().Be("Zero Hour Version"); + harness.StateStore.LoadedPaths.Should().Equal(generalsPaths, zeroHourPaths); + } + + [Fact] + public async Task InitDataAsync_RestoresPreviousGameCatalogWhenSwitchInitializationFailsAsync() + { + using var harness = new CatalogTestHarness(); + (LauncherPaths generalsPaths, LauncherPaths zeroHourPaths) = harness.CreateBothGamePaths(); + var manifestUri = new Uri("https://example.test/unavailable.yaml"); + harness.YamlReader.SetException( + manifestUri, + new IOException("Catalog unavailable.")); + harness.StateStore.StatesToLoadByGame[SupportedGame.Generals] = + CreateState(TestLauncherContent.Version("Shared Mod", "Generals Version", installed: true)); + harness.LocalContent.InstalledVersions = + [ + TestLauncherContent.Version("Shared Mod", "Generals Version", installed: true) + ]; + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, generalsPaths), + CancellationToken.None); + Func switchGame = () => harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, zeroHourPaths), + CancellationToken.None); + + await switchGame.Should().ThrowAsync(); + harness.Service.Data.Modifications.Should().ContainSingle() + .Which.Versions.Should().ContainSingle() + .Which.Version.Should().Be("Generals Version"); + + harness.Service.SaveLauncherData(); + harness.StateStore.SavedPaths.Should().ContainSingle().Which.Should().Be(generalsPaths); + } + + [Fact] + public async Task InitDataAsync_ReadsRemoteCatalogForInstalledModsAndDownloadsImagesAsync() + { + using var harness = new CatalogTestHarness(); + 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"); + harness.StateStore.StateToLoad = CreateState( + TestLauncherContent.Version("ShockWave", "1.0", installed: true)); + harness.LocalContent.InstalledVersions = + [ + TestLauncherContent.Version("ShockWave", "1.0", installed: true) + ]; + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = + [ + new() + { + ModName = "ShockWave", + ModLink = modUri.ToString() + } + ] + }); + harness.YamlReader.SetResult(modUri, new LegacyContentManifest + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2", + UIImageSourceLink = cardImageUri.ToString() + }); + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + + harness.Service.RepositoryModificationNames.Should().Equal("ShockWave"); + LauncherContent mod = harness.Service.Data.Modifications + .Should() + .ContainSingle(item => item.Name == "ShockWave") + .Subject; + mod.Versions.Should().Contain(version => version.Version == "1.2"); + harness.AssetDownloader.Calls.Should().Equal( + (cardImageUri, harness.Paths.GetModificationImageFilePath("ShockWave", "1.2.jpg"))); + } + + /// + /// Persisted state carries no remote metadata, so a themed launcher only survives an offline restart because + /// the palette that came down with the manifest was cached beside the artwork it belongs to. + /// + [Fact] + public async Task InitDataAsync_RestoresCachedPaletteWhenReopenedWithoutTheCatalogAsync() + { + using var harness = new CatalogTestHarness(); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var modUri = new Uri("https://example.test/contra.yaml"); + harness.StateStore.StateToLoad = CreateState( + TestLauncherContent.Version("Contra", "009", installed: true)); + harness.LocalContent.InstalledVersions = + [ + TestLauncherContent.Version("Contra", "009", installed: true) + ]; + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = + [ + new() + { + ModName = "Contra", + ModLink = modUri.ToString() + } + ] + }); + harness.YamlReader.SetResult(modUri, new LegacyContentManifest + { + ModificationType = ModificationType.Mod, + Name = "Contra", + Version = "009", + ColorsInformation = new LegacyContentThemeManifest { GenLauncherActiveColor = "#baff0c" } + }); + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + harness.Service.SaveLauncherData(); + harness.StateStore.StateToLoad = harness.StateStore.SavedStates.Single(); + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + + LauncherContentVersion restored = harness.Service.Data.Modifications.Should().ContainSingle().Subject + .Versions.Should().ContainSingle().Subject; + restored.Theme.Should().NotBeNull(); + restored.Theme!.GenLauncherActiveColor.Should().Be("#baff0c"); + } + + [Fact] + public async Task InitDataAsync_LoadsSelectedModPatchesAndAddonsAsync() + { + using var harness = new CatalogTestHarness(); + 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"); + harness.StateStore.StateToLoad = LauncherContentStateMapper.ToLauncherContentState( + TestLauncherContent.Catalog() + .WithMod("ShockWave", "1.0") + .Selected("ShockWave") + .Build() + .Data); + harness.LocalContent.InstalledVersions = + [ + TestLauncherContent.Version("ShockWave", "1.0", installed: true) + ]; + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = + [ + new() + { + ModName = "ShockWave", + ModLink = modUri.ToString(), + ModPatches = [patchUri.ToString()], + ModAddons = [addonUri.ToString()] + } + ] + }); + harness.YamlReader.SetResult( + modUri, + CreateRemoteVersion("ShockWave", "1.2", ModificationType.Mod)); + harness.YamlReader.SetResult( + patchUri, + CreateRemoteVersion("Balance", "2.0", ModificationType.Patch, "ShockWave")); + harness.YamlReader.SetResult( + addonUri, + CreateRemoteVersion("HD", "1.0", ModificationType.Addon, "ShockWave")); + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + await harness.Service.ReadPatchesAndAddonsForModAsync( + LauncherContentKey.ForModificationName("ShockWave"), + CancellationToken.None); + + LauncherContent shockWave = harness.Service.Data.Modifications.Should().ContainSingle().Subject; + harness.Service.Data.GetPatchesFor(shockWave).Select(patch => patch.Name).Should().Equal("Balance"); + harness.Service.Data.GetAddonsFor(shockWave, null).Select(addon => addon.Name).Should().Equal("HD"); + harness.YamlReader.GetReadCount(patchUri).Should().Be(1); + harness.YamlReader.GetReadCount(addonUri).Should().Be(1); + } + + [Fact] + public async Task ReadPatchesAndAddonsForModAsync_RetriesAfterPartialLoadFailureAsync() + { + using var harness = new CatalogTestHarness(); + 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"); + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = + { + new LegacyCatalogModificationReference + { + ModName = "ShockWave", + ModLink = modUri.ToString(), + ModPatches = { patchUri.ToString() } + } + } + }); + harness.YamlReader.SetResult( + modUri, + CreateRemoteVersion("ShockWave", "1.2", ModificationType.Mod)); + harness.YamlReader.SetHandler( + patchUri, + (callIndex, _) => callIndex == 1 + ? Task.FromException(new IOException("Temporary failure.")) + : Task.FromResult(CreateRemoteVersion( + "Balance", + "2.0", + ModificationType.Patch, + "ShockWave"))); + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + await harness.Service.AddRepositoryModificationAsync("ShockWave", CancellationToken.None); + var modification = LauncherContentKey.ForModificationName("ShockWave"); + + await harness.Service.ReadPatchesAndAddonsForModAsync(modification, CancellationToken.None); + await harness.Service.ReadPatchesAndAddonsForModAsync(modification, CancellationToken.None); + await harness.Service.ReadPatchesAndAddonsForModAsync(modification, CancellationToken.None); + + harness.Service.Data.Patches.SelectMany(patch => patch.Versions).Should() + .ContainSingle(version => version.Name == "Balance" && version.Version == "2.0"); + harness.YamlReader.GetReadCount(patchUri).Should().Be(2); + } + + [Fact] + public async Task ReadPatchesAndAddonsForModAsyncCoalescesConcurrent_LoadsAsync() + { + using var harness = new CatalogTestHarness(); + 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 patchReadStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releasePatchRead = + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = + { + new LegacyCatalogModificationReference + { + ModName = "ShockWave", + ModLink = modUri.ToString(), + ModPatches = { patchUri.ToString() } + } + } + }); + harness.YamlReader.SetResult( + modUri, + CreateRemoteVersion("ShockWave", "1.2", ModificationType.Mod)); + harness.YamlReader.SetHandler( + patchUri, + (_, _) => + { + patchReadStarted.TrySetResult(true); + return releasePatchRead.Task; + }); + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + await harness.Service.AddRepositoryModificationAsync("ShockWave", CancellationToken.None); + var modification = LauncherContentKey.ForModificationName("ShockWave"); + + Task firstLoad = harness.Service.ReadPatchesAndAddonsForModAsync(modification, CancellationToken.None); + await patchReadStarted.Task.WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + Task secondLoad = harness.Service.ReadPatchesAndAddonsForModAsync(modification, CancellationToken.None); + releasePatchRead.SetResult( + CreateRemoteVersion("Balance", "2.0", ModificationType.Patch, "ShockWave")); + await Task.WhenAll(firstLoad, secondLoad); + + harness.Service.Data.Patches.SelectMany(patch => patch.Versions).Should() + .ContainSingle(version => version.Name == "Balance" && version.Version == "2.0"); + harness.YamlReader.GetReadCount(patchUri).Should().Be(1); + } + + [Fact] + public async Task ReadOriginalGameAddonsAndPatchesAsync_LoadsChildContentOnceAsync() + { + using var harness = new CatalogTestHarness(); + 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"); + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + originalGamePatches = [patchUri.ToString()], + originalGameAddons = [addonUri.ToString()] + }); + harness.YamlReader.SetResult( + patchUri, + CreateRemoteVersion("GenPatcher", "1.0", ModificationType.Patch)); + harness.YamlReader.SetResult( + addonUri, + CreateRemoteVersion("ControlBar", "1.0", ModificationType.Addon)); + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + await harness.Service.ReadOriginalGameAddonsAndPatchesAsync(CancellationToken.None); + await harness.Service.ReadOriginalGameAddonsAndPatchesAsync(CancellationToken.None); + harness.Service.UpdateLocalModificationsData(); + + harness.Service.Data.GetPatchesFor(null).Select(patch => patch.Name).Should().Equal("GenPatcher"); + harness.Service.Data.GetAddonsFor(null, null).Select(addon => addon.Name).Should().Equal("ControlBar"); + harness.YamlReader.GetReadCount(patchUri).Should().Be(1); + harness.YamlReader.GetReadCount(addonUri).Should().Be(1); + } + + [Fact] + public async Task ReadOriginalGameAddonsAndPatchesAsync_RetriesAfterPartialLoadFailureAsync() + { + using var harness = new CatalogTestHarness(); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var patchUri = new Uri("https://example.test/original-patch.yaml"); + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + originalGamePatches = { patchUri.ToString() } + }); + harness.YamlReader.SetHandler( + patchUri, + (callIndex, _) => callIndex == 1 + ? Task.FromException(new IOException("Temporary failure.")) + : Task.FromResult(CreateRemoteVersion("GenPatcher", "1.0", ModificationType.Patch))); + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + + await harness.Service.ReadOriginalGameAddonsAndPatchesAsync(CancellationToken.None); + await harness.Service.ReadOriginalGameAddonsAndPatchesAsync(CancellationToken.None); + await harness.Service.ReadOriginalGameAddonsAndPatchesAsync(CancellationToken.None); + + harness.Service.Data.Patches.SelectMany(patch => patch.Versions).Should() + .ContainSingle(version => version.Name == "GenPatcher"); + harness.YamlReader.GetReadCount(patchUri).Should().Be(2); + } + + [Fact] + public async Task ReadOriginalGameAddonsAndPatchesAsync_ReturnsWhenCatalogIsDisconnectedAsync() + { + using var harness = new CatalogTestHarness(); + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + + await harness.Service.ReadOriginalGameAddonsAndPatchesAsync(CancellationToken.None); + + harness.YamlReader.GetReadCount().Should().Be(0); + } + + [Fact] + public async Task ReadPatchesAndAddonsForModAsync_ReturnsWhenManifestLookupIsMissingAsync() + { + using var harness = new CatalogTestHarness(); + var manifestUri = new Uri("https://example.test/repos.yaml"); + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument()); + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + + await harness.Service.ReadPatchesAndAddonsForModAsync( + LauncherContentKey.ForModificationName("Missing"), + CancellationToken.None); + + harness.YamlReader.GetReadCount().Should().Be(0); + } + + [Fact] + public async Task InitDataAsync_DownloadsAdvertisingMetadataAndImagesAsync() + { + using var harness = new CatalogTestHarness(); + 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"); + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + AdvData = + [ + new() + { + ModName = "RiseOfTheReds", + ModLink = advertisingUri.ToString(), + ImagesData = [imageUri.ToString()] + } + ] + }); + harness.YamlReader.SetResult(advertisingUri, new LegacyContentManifest + { + ModificationType = ModificationType.Advertising, + Name = "RiseOfTheReds", + Version = "1.87" + }); + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + + LauncherContentVersion? advertising = harness.Service.Advertising; + advertising.Should().NotBeNull(); + advertising!.Name.Should().Be("RiseOfTheReds"); + advertising.Version.Should().Be("1.87"); + harness.AssetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == imageUri && + call.DestinationFilePath == + harness.Paths.GetModificationImageFilePath("RiseOfTheReds", "0.jpg")); + } + + [Fact] + public async Task InitDataAsync_LeavesAdvertisingEmptyWhenManifestDownloadFailsAsync() + { + using var harness = new CatalogTestHarness(); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var advertisingUri = new Uri("https://example.test/advertising.yaml"); + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + AdvData = + [ + new() + { + ModName = "RiseOfTheReds", + ModLink = advertisingUri.ToString(), + ImagesData = ["https://cdn.example.test/advertising.jpg"] + } + ] + }); + harness.YamlReader.SetException( + advertisingUri, + new IOException("Manifest unavailable.")); + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + + harness.Service.Advertising.Should().BeNull(); + harness.AssetDownloader.Calls.Should().BeEmpty(); + } + + [Fact] + public async Task PersistedSelectionState_IsLoadedAsync() + { + using var harness = new CatalogTestHarness(); + LauncherData persistedCatalog = TestLauncherContent.Catalog() + .WithMod("ShockWave", "1.2") + .WithMod("Contra", "009") + .WithPatch("ShockWave", "BalancePatch", "2.0") + .WithAddon("ShockWave", "HDTextures", "1.0") + .WithAddon("BalancePatch", "PatchAddon", "1.1") + .Selected("ShockWave") + .Selected("BalancePatch", ModificationType.Patch, "ShockWave") + .Selected("HDTextures", ModificationType.Addon, "ShockWave") + .Selected("PatchAddon", ModificationType.Addon, "BalancePatch") + .Build() + .Data; + var catalogState = LauncherContentStateMapper.ToLauncherContentState(persistedCatalog); + harness.StateStore.StateToLoad = catalogState; + harness.LocalContent.InstalledVersions = GetVersions(catalogState); + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + + LauncherContent selectedModification = harness.Service.Data.Modifications.Single(modification => + modification.IsSelected); + LauncherContent selectedPatch = harness.Service.Data.Patches.Single(patch => patch.IsSelected); + selectedModification.Versions.Should().ContainSingle(version => + version.Name == "ShockWave" && version.Version == "1.2" && version.Installation.IsSelected); + selectedPatch.Versions.Should().ContainSingle(version => + version.Name == "BalancePatch" && version.Version == "2.0" && version.Installation.IsSelected); + harness.Service.Data.GetPatchesFor(selectedModification) + .Should().ContainSingle(patch => patch.Name == "BalancePatch"); + harness.Service.Data.GetAddonsFor(selectedModification, selectedPatch) + .Should().Contain(addon => addon.Name == "HDTextures") + .And.Contain(addon => addon.Name == "PatchAddon"); + harness.Service.Data.Addons.Should().OnlyContain(addon => + addon.IsSelected && + addon.Versions.Count(version => version.Installation.IsSelected) == 1); + harness.Service.Data.GetAllModificationVersions().Should().HaveCount(2); + } + + [Fact] + public async Task UninstallVersion_DeletesLocalFilesAndReconcilesCatalogAsync() + { + using var harness = new CatalogTestHarness(); + LauncherContentVersion version = TestLauncherContent.Version("ShockWave", "1.0", installed: true); + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + + harness.Service.UninstallVersion(version.ContentKey); + + harness.LocalContent.DeletedVersions.Should().ContainSingle(request => + request.Paths == harness.Paths && + request.ContentKey.ContentType == ModificationType.Mod && + request.ContentKey.Name == "ShockWave" && + request.ContentKey.Version == "1.0"); + harness.LocalContent.ImageDeletionRequests.Should().BeEmpty(); + } + + [Fact] + public async Task AddRepositoryModificationAsync_AddsRemoteModAndCachesImagesExactlyOnceAsync() + { + using var harness = new CatalogTestHarness(); + 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"); + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = + [ + new() + { + ModName = "Contra", + ModLink = modUri.ToString() + } + ] + }); + harness.YamlReader.SetResult(modUri, new LegacyContentManifest + { + ModificationType = ModificationType.Mod, + Name = "Contra", + Version = "009", + UIImageSourceLink = imageUri.ToString() + }); + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + + LauncherContentVersion downloadedVersion = await harness.Service.AddRepositoryModificationAsync( + "Contra", + CancellationToken.None); + + downloadedVersion.Name.Should().Be("Contra"); + downloadedVersion.Version.Should().Be("009"); + LauncherContent addedModification = harness.Service.Data.Modifications.Should().ContainSingle().Subject; + addedModification.Name.Should().Be("Contra"); + addedModification.Versions.Should().ContainSingle().Which.Should().BeSameAs(downloadedVersion); + harness.AssetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == imageUri && + call.DestinationFilePath == harness.Paths.GetModificationImageFilePath("Contra", "009.png")); + } + + [Fact] + public async Task InitDataAsync_WaitsForOldGameMetadataLoadBeforeSwitchingCatalogAsync() + { + using var harness = new CatalogTestHarness(); + (LauncherPaths generalsPaths, LauncherPaths zeroHourPaths) = harness.CreateBothGamePaths(); + 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"); + var metadataReadStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releaseMetadataRead = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = + { + new LegacyCatalogModificationReference + { + ModName = "Contra", + ModLink = modUri.ToString() + } + } + }); + harness.YamlReader.SetHandler( + modUri, + async (_, cancellationToken) => + { + metadataReadStarted.SetResult(); + await releaseMetadataRead.Task.WaitAsync(cancellationToken); + return new LegacyContentManifest + { + ModificationType = ModificationType.Mod, + Name = "Contra", + Version = "009", + UIImageSourceLink = imageUri.ToString() + }; + }); + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, generalsPaths), + CancellationToken.None); + Task oldGameDownload = + harness.Service.AddRepositoryModificationAsync("Contra", CancellationToken.None); + await metadataReadStarted.Task; + Task switchGame = harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, zeroHourPaths), + CancellationToken.None); + + releaseMetadataRead.SetResult(); + await oldGameDownload; + await switchGame; + + harness.Service.Data.Modifications.Should().BeEmpty(); + harness.AssetDownloader.Calls.Should().ContainSingle(call => + call.SourceUri == imageUri && + call.DestinationFilePath == generalsPaths.GetModificationImageFilePath("Contra", "009.png")); + } + + [Fact] + public async Task InitDataAsync_WaitsForDirectMetadataReadBeforeSwitchingCatalogAsync() + { + using var harness = new CatalogTestHarness(); + (LauncherPaths generalsPaths, LauncherPaths zeroHourPaths) = harness.CreateBothGamePaths(); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var modUri = new Uri("https://example.test/contra.yaml"); + var metadataReadStarted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + var releaseMetadataRead = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = + { + new LegacyCatalogModificationReference + { + ModName = "Contra", + ModLink = modUri.ToString() + } + } + }); + harness.YamlReader.SetHandler( + modUri, + async (_, cancellationToken) => + { + metadataReadStarted.SetResult(); + await releaseMetadataRead.Task.WaitAsync(cancellationToken); + return new LegacyContentManifest + { + ModificationType = ModificationType.Mod, + Name = "Contra", + Version = "009" + }; + }); + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, generalsPaths), + CancellationToken.None); + Task oldGameMetadata = harness.Service.GetRepositoryModificationMetadataAsync( + "Contra", + CancellationToken.None); + await metadataReadStarted.Task; + Task switchGame = harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, zeroHourPaths), + CancellationToken.None); + + bool switchCompletedBeforeMetadataRead = switchGame.IsCompleted; + releaseMetadataRead.SetResult(); + LauncherContentVersion metadata = await oldGameMetadata; + await switchGame; + + switchCompletedBeforeMetadataRead.Should().BeFalse(); + metadata.Version.Should().Be("009"); + harness.Service.Data.Modifications.Should().BeEmpty(); + Func readOldMetadataFromNewSession = () => harness.Service.GetRepositoryModificationMetadataAsync( + "Contra", + CancellationToken.None); + await readOldMetadataFromNewSession.Should().ThrowAsync(); + } + + [Fact] + public async Task DiscardVersion_DeletesFolderAndCatalogVersionAsync() + { + using var harness = new CatalogTestHarness(); + LauncherContentVersion version = TestLauncherContent.Version("ShockWave", "1.0", installed: true); + harness.StateStore.StateToLoad = CreateState(version); + harness.LocalContent.InstalledVersions = [version]; + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + harness.LocalContent.InstalledVersions = []; + + harness.Service.DiscardVersion(version.ContentKey); + + harness.Service.Data.Modifications.Select(modification => modification.Name).Should() + .NotContain("ShockWave"); + harness.LocalContent.DeletedVersions.Should().ContainSingle(request => + request.Paths == harness.Paths && + request.ContentKey.ContentType == ModificationType.Mod && + request.ContentKey.Name == "ShockWave" && + request.ContentKey.Version == "1.0"); + harness.LocalContent.ImageDeletionRequests.Should().ContainSingle(request => + request.Paths == harness.Paths && + request.ContentKey == version.ContentKey && + !request.ContentNames.Contains("ShockWave") && + ReferenceEquals(request.Data, harness.Service.Data)); + } + + [Fact] + public async Task DiscardContent_DeletesEveryVersionFolderAndCatalogEntryAsync() + { + using var harness = new CatalogTestHarness(); + LauncherContentVersion version = TestLauncherContent.Version("ShockWave", "1.0", installed: true); + LauncherContentVersion secondVersion = TestLauncherContent.Version("ShockWave", "2.0", installed: true); + harness.StateStore.StateToLoad = CreateState(version, secondVersion); + harness.LocalContent.InstalledVersions = [version, secondVersion]; + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + harness.LocalContent.InstalledVersions = []; + + harness.Service.DiscardContent(version.ContentKey); + + harness.Service.Data.Modifications.Select(modification => modification.Name).Should() + .NotContain("ShockWave"); + harness.LocalContent.DeletedContents.Should().ContainSingle(request => + request.Paths == harness.Paths && + request.ContentKey.ContentType == ModificationType.Mod && + request.ContentKey.Name == "ShockWave" && + request.ContentKey.Version == "1.0"); + harness.LocalContent.DeletedVersions.Should().BeEmpty(); + harness.LocalContent.ImageDeletionRequests.Should().ContainSingle(request => + request.Paths == harness.Paths && + request.ContentKey == version.ContentKey && + !request.ContentNames.Contains("ShockWave") && + ReferenceEquals(request.Data, harness.Service.Data)); + } + + [Fact] + public async Task SaveLauncherData_PersistsInstalledAndAddedRepositoryModsAsync() + { + using var harness = new CatalogTestHarness(); + LauncherContentVersion installedVersion = TestLauncherContent.Version( + "ShockWave", + "1.0", + installed: true, + isSelected: true); + LauncherContentVersion repositoryVersion = TestLauncherContent.Version( + "Contra", + "2.0", + sourceKind: ContentSourceKind.ManagedSingleFile); + harness.StateStore.StateToLoad = CreateState(installedVersion, repositoryVersion); + harness.LocalContent.InstalledVersions = [installedVersion]; + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + + harness.Service.SaveLauncherData(); + + harness.StateStore.SavedStates.Should().ContainSingle(state => + state.Modifications.Count == 2 && + 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 && + state.Modifications[1].Name == "Contra" && + state.Modifications[1].ModificationVersions.Count == 1 && + state.Modifications[1].ModificationVersions[0].Version == "2.0" && + !state.Modifications[1].ModificationVersions[0].Installed); + } + + [Fact] + public async Task PersistedManagedRepositoryMod_SurvivesDisconnectedCatalogReloadAsync() + { + using var harness = new CatalogTestHarness(); + harness.StateStore.StateToLoad = CreateState(TestLauncherContent.Version( + "Contra", + "2.0", + sourceKind: ContentSourceKind.ManagedSingleFile)); + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + harness.Service.SaveLauncherData(); + harness.StateStore.StateToLoad = harness.StateStore.SavedStates.Single(); + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + + harness.Service.Data.Modifications.Should().ContainSingle() + .Which.Name.Should().Be("Contra"); + } + + [Fact] + public async Task SaveLauncherData_PersistsOriginalGameSelectionWithoutModificationCardsAsync() + { + using var harness = new CatalogTestHarness(); + LauncherContentVersion patch = TestLauncherContent.Version( + "Original Patch", + "1.0", + ModificationType.Patch, + LauncherContentKey.OriginalGame.Name, + installed: true, + isSelected: true); + harness.StateStore.StateToLoad = CreateState(patch); + harness.LocalContent.InstalledVersions = [patch]; + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + + harness.Service.SaveLauncherData(); + + LauncherContentState savedState = harness.StateStore.SavedStates.Should().ContainSingle().Subject; + savedState.Modifications.Should().BeEmpty(); + LauncherContentEntryState savedPatch = savedState.Patches.Should().ContainSingle().Subject; + savedPatch.Name.Should().Be("Original Patch"); + savedPatch.IsSelected.Should().BeTrue(); + savedPatch.ModificationVersions.Should().ContainSingle().Which.IsSelected.Should().BeTrue(); + } + + [Fact] + public async Task SaveLauncherDataWhenPersistence_FailsPreservesCatalogForRetryAsync() + { + using var harness = new CatalogTestHarness(); + int saveAttempts = 0; + harness.StateStore.SaveHandler = _ => + { + saveAttempts++; + if (saveAttempts == 1) + { + throw new IOException("Catalog file is locked."); + } + }; + LauncherContentVersion version = TestLauncherContent.Version( + "ShockWave", + "1.2", + installed: true, + sourceKind: ContentSourceKind.Manual); + harness.StateStore.StateToLoad = CreateState(version); + harness.LocalContent.InstalledVersions = [version]; + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + Action firstSave = harness.Service.SaveLauncherData; + + firstSave.Should().Throw() + .WithInnerException(); + harness.Service.Data.Modifications.Should().ContainSingle(modification => + modification.Name == "ShockWave" && + modification.Versions.Single().Installation.Installed); + + harness.Service.SaveLauncherData(); + + saveAttempts.Should().Be(2); + harness.StateStore.SavedStates.Should().HaveCount(2); + harness.StateStore.SavedStates.Should().OnlyContain(state => + state.Modifications.Count == 1 && + state.Modifications[0].Name == "ShockWave" && + state.Modifications[0].ModificationVersions[0].ContentSourceKind == ContentSourceKind.Manual); + } + + /// + /// Saved state is only half the picture. A modification whose folder is on disk but which the state file never + /// recorded — a crash between installing and saving is enough — still has to appear in the list. + /// + [Fact] + public async Task InitDataAsync_AddsInstalledContentThatSavedStateDoesNotRecordAsync() + { + using var harness = new CatalogTestHarness(); + harness.LocalContent.InstalledVersions = + [ + TestLauncherContent.Version("ShockWave", "1.0", installed: true) + ]; + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + + harness.Service.Data.Modifications.Should().ContainSingle() + .Which.Versions.Should().ContainSingle() + .Which.Version.Should().Be("1.0"); + } + + /// + /// Removing a version rescans the mods folder in the same pass, so what the list shows afterwards is what is + /// actually on disk rather than what was there when the session started. + /// + [Fact] + public async Task UninstallVersion_RescansLocalContentAsync() + { + using var harness = new CatalogTestHarness(); + LauncherContentVersion version = TestLauncherContent.Version("ShockWave", "1.0", installed: true); + harness.StateStore.StateToLoad = CreateState(version); + harness.LocalContent.InstalledVersions = [version]; + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + harness.LocalContent.InstalledVersions = + [ + TestLauncherContent.Version("Contra", "009", installed: true) + ]; + + harness.Service.UninstallVersion(version.ContentKey); + + harness.Service.Data.Modifications.Select(modification => modification.Name).Should().Equal("Contra"); + } + + [Fact] + public async Task DiscardVersion_RescansLocalContentAsync() + { + using var harness = new CatalogTestHarness(); + LauncherContentVersion version = TestLauncherContent.Version("ShockWave", "1.0", installed: true); + harness.StateStore.StateToLoad = CreateState(version); + harness.LocalContent.InstalledVersions = [version]; + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + harness.LocalContent.InstalledVersions = + [ + TestLauncherContent.Version("Contra", "009", installed: true) + ]; + + harness.Service.DiscardVersion(version.ContentKey); + + harness.Service.Data.Modifications.Select(modification => modification.Name).Should().Equal("Contra"); + } + + [Fact] + public async Task DiscardContent_RescansLocalContentAsync() + { + using var harness = new CatalogTestHarness(); + LauncherContentVersion version = TestLauncherContent.Version("ShockWave", "1.0", installed: true); + harness.StateStore.StateToLoad = CreateState(version); + harness.LocalContent.InstalledVersions = [version]; + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(null, harness.Paths), + CancellationToken.None); + harness.LocalContent.InstalledVersions = + [ + TestLauncherContent.Version("Contra", "009", installed: true) + ]; + + harness.Service.DiscardContent(version.ContentKey); + + harness.Service.Data.Modifications.Select(modification => modification.Name).Should().Equal("Contra"); + } + + /// + /// A version the catalog still publishes stays on its card as "not installed" when its folder is gone, so the + /// user can download it again. Only content nobody publishes any more is dropped from the list outright. + /// + [Fact] + public async Task UninstallVersion_KeepsARemotelyPublishedVersionAsNotInstalledAsync() + { + using var harness = new CatalogTestHarness(); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var modUri = new Uri("https://example.test/shockwave.yaml"); + LauncherContentVersion version = TestLauncherContent.Version("ShockWave", "1.2", installed: true); + harness.StateStore.StateToLoad = CreateState(version); + harness.LocalContent.InstalledVersions = [version]; + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = + [ + new() + { + ModName = "ShockWave", + ModLink = modUri.ToString() + } + ] + }); + harness.YamlReader.SetResult(modUri, new LegacyContentManifest + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2" + }); + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + harness.LocalContent.InstalledVersions = []; + + harness.Service.UninstallVersion(version.ContentKey); + + LauncherContentVersion remaining = harness.Service.Data.Modifications.Should().ContainSingle().Subject + .Versions.Should().ContainSingle().Subject; + remaining.Version.Should().Be("1.2"); + remaining.Installation.Installed.Should().BeFalse(); + } + + /// + /// Repository metadata is reused once read. The details pane asks for it every time it is opened, and each + /// read is a round trip to a third-party backend. + /// + [Fact] + public async Task GetRepositoryModificationMetadataAsync_ReadsTheManifestOnceForRepeatedRequestsAsync() + { + using var harness = new CatalogTestHarness(); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var modUri = new Uri("https://example.test/contra.yaml"); + harness.YamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + modDatas = + [ + new() + { + ModName = "Contra", + ModLink = modUri.ToString() + } + ] + }); + harness.YamlReader.SetResult(modUri, new LegacyContentManifest + { + ModificationType = ModificationType.Mod, + Name = "Contra", + Version = "009" + }); + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + + LauncherContentVersion firstRead = await harness.Service.GetRepositoryModificationMetadataAsync( + "Contra", + CancellationToken.None); + LauncherContentVersion secondRead = await harness.Service.GetRepositoryModificationMetadataAsync( + "Contra", + CancellationToken.None); + + secondRead.Should().BeSameAs(firstRead); + harness.YamlReader.GetReadCount(modUri).Should().Be(1); + } + + /// + /// Image caching runs a bounded number of downloads at a time so a first start does not saturate the + /// connection. The bound limits how many run at once, not how many are cached. + /// + [Fact] + public async Task InitDataAsync_CachesImagesForMoreModificationsThanItDownloadsConcurrentlyAsync() + { + using var harness = new CatalogTestHarness(); + var manifestUri = new Uri("https://example.test/repos.yaml"); + var catalogReferences = new List(); + var installedVersions = new List(); + for (int index = 0; index < 12; index++) + { + var modUri = new Uri($"https://example.test/mod-{index}.yaml"); + harness.YamlReader.SetResult(modUri, new LegacyContentManifest + { + ModificationType = ModificationType.Mod, + Name = $"Mod {index}", + Version = "1.0", + UIImageSourceLink = $"https://cdn.example.test/mod-{index}.png" + }); + catalogReferences.Add(new LegacyCatalogModificationReference + { + ModName = $"Mod {index}", + ModLink = modUri.ToString() + }); + installedVersions.Add(TestLauncherContent.Version($"Mod {index}", "1.0", installed: true)); + } + + harness.StateStore.StateToLoad = CreateState([.. installedVersions]); + harness.LocalContent.InstalledVersions = installedVersions; + harness.YamlReader.SetResult( + manifestUri, + new LegacyLauncherCatalogDocument { modDatas = catalogReferences }); + + await harness.Service.InitDataAsync( + new LauncherContentCatalogInitializationRequest(manifestUri, harness.Paths), + CancellationToken.None); + + harness.AssetDownloader.Calls.Should().HaveCount(12); + } + + private static LauncherContentState CreateState(params LauncherContentVersion[] versions) + { + var data = new LauncherData(); + foreach (LauncherContentVersion version in versions) + { + data.AddOrUpdate(version); + } + + return LauncherContentStateMapper.ToLauncherContentState(data); + } + + private static LegacyContentManifest CreateRemoteVersion( + string name, + string version, + ModificationType modificationType, + string parentContentName = "") + { + return new LegacyContentManifest + { + ModificationType = modificationType, + Name = name, + Version = version, + DependenceName = parentContentName + }; + } + + private static IReadOnlyList GetVersions(LauncherContentState state) + { + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + return launcherData.Modifications + .Concat(launcherData.Patches) + .Concat(launcherData.Addons) + .SelectMany(modification => modification.Versions) + .ToList(); + } +} + +/// +/// Owns one catalog session: its temporary directory, its collaborators, and the service built from them, so a +/// test arranges only the part it is actually about. +/// +/// +/// The service is built on first use, which is what lets a test finish configuring the stubs it needs first. One +/// palette cache instance is shared between the image cache and the catalog, as in production: what the download +/// path writes is what a later offline start reads back. +/// +file sealed class CatalogTestHarness : IDisposable +{ + private readonly TestDirectory _directory = new(); + + private readonly Lazy _paths; + + private readonly Lazy _service; + + public CatalogTestHarness() + { + _paths = new Lazy(() => TestLauncherPaths.Create(_directory)); + _service = new Lazy(BuildService); + } + + public RecordingLauncherContentStateStore StateStore { get; } = new(); + + public RecordingLocalLauncherContentService LocalContent { get; } = new(); + + public StubRemoteYamlDocumentReader YamlReader { get; } = new(); + + public RecordingRemoteAssetDownloader AssetDownloader { get; } = new(); + + public IModificationThemeCache ThemeCache { get; } = new FakeModificationThemeCache(); + + public LauncherPaths Paths => _paths.Value; + + public LauncherContentCatalogService Service => _service.Value; + + public void Dispose() + { + _directory.Dispose(); + } + + /// + /// Builds both supported games from one storage root, which is the only arrangement a game switch is valid in. + /// + public (LauncherPaths Generals, LauncherPaths ZeroHour) CreateBothGamePaths() + { + (_, LauncherPaths generalsPaths, LauncherPaths zeroHourPaths) = + TestLauncherPaths.CreateTwoGameRuntime(_directory); + return (generalsPaths, zeroHourPaths); + } + + private LauncherContentCatalogService BuildService() + { + return new LauncherContentCatalogService( + StateStore, + new RemoteLauncherCatalogClient( + YamlReader, + NullLogger.Instance), + new LauncherCatalogImageCache( + AssetDownloader, + ThemeCache, + NullLogger.Instance), + new LauncherLocalContentReconciler( + LocalContent, + NullLogger.Instance), + ThemeCache, + NullLogger.Instance); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentStateMapperTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentStateMapperTests.cs new file mode 100644 index 00000000..07b1c61a --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherContentStateMapperTests.cs @@ -0,0 +1,702 @@ +using System.Linq; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Services; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class LauncherContentStateMapperTests +{ + [Fact] + public void ToLauncherData_RestoresSelectedInstalledContentState() + { + var state = new LauncherContentState + { + Modifications = + [ + CreateEntry("ShockWave", string.Empty, ModificationType.Mod, "1.0", true) + ], + Patches = + [ + CreateEntry("ShockWave Patch", "ShockWave", ModificationType.Patch, "1.1", true) + ], + Addons = + [ + CreateEntry("Music Pack", "ShockWave Patch", ModificationType.Addon, "2.0", true) + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + LauncherContentVersion modVersion = launcherData.Modifications.Should().ContainSingle().Subject + .Versions.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.Installation.Installed.Should().BeTrue(); + modVersion.Installation.IsSelected.Should().BeTrue(); + + LauncherContentVersion patchVersion = launcherData.Patches.Should().ContainSingle().Subject + .Versions.Should().ContainSingle().Subject; + patchVersion.Name.Should().Be("ShockWave Patch"); + patchVersion.ParentContentName.Should().Be("ShockWave"); + patchVersion.ModificationType.Should().Be(ModificationType.Patch); + + LauncherContentVersion addonVersion = launcherData.Addons.Should().ContainSingle().Subject + .Versions.Should().ContainSingle().Subject; + addonVersion.Name.Should().Be("Music Pack"); + addonVersion.ParentContentName.Should().Be("ShockWave Patch"); + addonVersion.ModificationType.Should().Be(ModificationType.Addon); + } + + [Fact] + public void ToLauncherData_RestoresPersistedEntryOrder() + { + var state = new LauncherContentState + { + Modifications = + [ + CreateEntry("Second", string.Empty, ModificationType.Mod, "1.0", false, numberInList: 1), + CreateEntry("First", string.Empty, ModificationType.Mod, "1.0", false, numberInList: 0) + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + launcherData.Modifications + .OrderBy(modification => modification.NumberInList) + .Select(modification => modification.Name) + .Should() + .Equal("First", "Second"); + } + + [Fact] + public void ToLauncherData_DoesNotSelectEntryFromStaleVersionSelection() + { + var state = new LauncherContentState + { + Modifications = + [ + CreateEntry("ShockWave", string.Empty, ModificationType.Mod, "1.0", false, true) + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + LauncherContent modification = launcherData.Modifications.Should().ContainSingle().Subject; + modification.IsSelected.Should().BeFalse(); + modification.Versions.Should().ContainSingle().Which.Installation.IsSelected.Should().BeFalse(); + } + + [Fact] + public void ToLauncherData_UsesEntryTypeForIncompleteLegacyChildVersionRecords() + { + var state = new LauncherContentState + { + Addons = + [ + new() + { + Name = "Compatibility Addon", + DependenceName = "ShockWave", + ModificationType = ModificationType.Addon, + ModificationVersions = + [ + new() + { + Version = "1.0", + Installed = true, + ContentSourceKind = ContentSourceKind.Manual + } + ] + } + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + LauncherContentVersion version = launcherData.Addons.Should().ContainSingle().Subject + .Versions.Should().ContainSingle().Subject; + version.Name.Should().Be("Compatibility Addon"); + version.ParentContentName.Should().Be("ShockWave"); + version.ModificationType.Should().Be(ModificationType.Addon); + version.Installation.ContentSourceKind.Should().Be(ContentSourceKind.Manual); + } + + /// + /// Legacy records can leave both type slots at their default, which reads as "mod" for a record that is + /// stored under the add-ons section. The section it is stored in is what settles it. + /// + [Fact] + public void ToLauncherData_UsesTheStoredSectionWhenNeitherEntryNorVersionRecordsAChildType() + { + var state = new LauncherContentState + { + Addons = + [ + new() + { + Name = "Compatibility Addon", + DependenceName = "ShockWave", + ModificationVersions = + [ + new() + { + Version = "1.0", + Installed = true + } + ] + } + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + launcherData.Addons.Should().ContainSingle().Subject + .Versions.Should().ContainSingle().Subject + .ModificationType.Should().Be(ModificationType.Addon); + } + + /// + /// Older saved data recorded installation on the card rather than on each version, so an entry that claims to + /// be installed still has to produce an installed version. + /// + [Fact] + public void ToLauncherData_TreatsAVersionAsInstalledWhenOnlyItsEntrySaysSo() + { + var state = new LauncherContentState + { + Modifications = + [ + new() + { + Name = "ShockWave", + ModificationType = ModificationType.Mod, + Installed = true, + ModificationVersions = + [ + new() + { + Version = "1.0", + Installed = false + } + ] + } + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + launcherData.Modifications.Should().ContainSingle().Subject + .Versions.Should().ContainSingle().Subject + .Installation.Installed.Should().BeTrue(); + } + + /// + /// Persisted state deliberately holds no remote metadata, so the palette a themed modification wears after an + /// offline restart can only come from the cache, looked up under the identity the entry carries. + /// + [Fact] + public void ToLauncherData_RestoresCachedPaletteForEachVersion() + { + var themeCache = new FakeModificationThemeCache(); + var contraTheme = new LauncherContentTheme { GenLauncherActiveColor = "#baff0c" }; + var shockWaveTheme = new LauncherContentTheme { GenLauncherActiveColor = "#00e3ff" }; + themeCache.Save( + new LauncherContentKey(ModificationType.Mod, string.Empty, "Contra", "009"), + contraTheme); + themeCache.Save( + new LauncherContentKey(ModificationType.Mod, string.Empty, "ShockWave", "1.2"), + shockWaveTheme); + var state = new LauncherContentState + { + Modifications = + [ + new() + { + Name = "Contra", + ModificationType = ModificationType.Mod, + ModificationVersions = [new() { Version = "009", Installed = true }] + }, + new() + { + Name = "ShockWave", + ModificationType = ModificationType.Mod, + ModificationVersions = [new() { Version = "1.2", Installed = true }] + } + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state, themeCache); + + launcherData.Modifications + .Select(modification => modification.Versions.Single().Theme) + .Should().Equal(contraTheme, shockWaveTheme); + } + + /// + /// A suspended download has partial content on disk but is neither installed nor selected, so it has to + /// persist on its own merit or the next session would forget it and start over. + /// + [Fact] + public void ToLauncherContentState_PersistsSuspendedDownloadThatIsNeitherInstalledNorSelected() + { + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(TestLauncherContent.Version( + "Contra", + "009", + sourceKind: ContentSourceKind.Manual, + downloadSuspended: true, + suspendedProgressPercentage: 42)); + + var state = LauncherContentStateMapper.ToLauncherContentState(launcherData); + + LauncherContentVersionState version = state.Modifications.Should().ContainSingle().Subject + .ModificationVersions.Should().ContainSingle().Subject; + version.Installed.Should().BeFalse(); + version.IsSelected.Should().BeFalse(); + version.DownloadSuspended.Should().BeTrue(); + version.SuspendedProgressPercentage.Should().Be(42); + } + + [Fact] + public void ToLauncherData_RestoresSuspendedDownloadProgress() + { + var state = new LauncherContentState + { + Modifications = + [ + new() + { + Name = "Contra", + ModificationType = ModificationType.Mod, + ModificationVersions = + [ + new() + { + Version = "009", + DownloadSuspended = true, + SuspendedProgressPercentage = 42, + ContentSourceKind = ContentSourceKind.Manual + } + ] + } + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + LauncherContentInstallation installation = launcherData.Modifications.Should().ContainSingle().Subject + .Versions.Should().ContainSingle().Subject.Installation; + installation.Installed.Should().BeFalse(); + installation.DownloadSuspended.Should().BeTrue(); + installation.SuspendedProgressPercentage.Should().Be(42); + } + + [Fact] + public void ToLauncherData_IgnoresLegacyAdvertisingVersionRecords() + { + var state = new LauncherContentState + { + Modifications = + [ + new() + { + Name = "Featured", + ModificationType = ModificationType.Advertising, + ModificationVersions = + [ + new() + { + Name = "Featured", + Version = "2.0", + ModificationType = ModificationType.Advertising, + Installed = true + } + ] + } + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + launcherData.Modifications.Should().BeEmpty(); + launcherData.Patches.Should().BeEmpty(); + launcherData.Addons.Should().BeEmpty(); + } + + [Fact] + public void ToLauncherContentState_PersistsAddedRepositoryModsButFiltersUninstalledChildren() + { + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(new LauncherContentVersion + { + Installation = new LauncherContentInstallation { Installed = true }, + ModificationType = ModificationType.Mod, + Name = "Installed", + Version = "1.0" + }); + launcherData.AddOrUpdate(new LauncherContentVersion + { + Installation = new LauncherContentInstallation + { + ContentSourceKind = ContentSourceKind.ManagedSingleFile + }, + ModificationType = ModificationType.Mod, + Name = "Added", + Version = "2.0" + }); + launcherData.AddOrUpdate(new LauncherContentVersion + { + Installation = new LauncherContentInstallation + { + ContentSourceKind = ContentSourceKind.ManagedSingleFile + }, + ModificationType = ModificationType.Patch, + ParentContentName = "Installed", + Name = "Uninstalled Child", + Version = "1.0" + }); + + var state = LauncherContentStateMapper.ToLauncherContentState(launcherData); + + state.Modifications.Select(entry => entry.Name).Should().Equal("Installed", "Added"); + state.Modifications[0].ModificationVersions.Should().ContainSingle() + .Which.Version.Should().Be("1.0"); + state.Modifications[1].ModificationVersions.Should().ContainSingle() + .Which.Version.Should().Be("2.0"); + state.Patches.Should().BeEmpty(); + } + + [Fact] + public void ToLauncherContentState_DoesNotPersistVersionSelectionForUnselectedEntry() + { + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(new LauncherContentVersion + { + Installation = new LauncherContentInstallation { Installed = true, IsSelected = true }, + ModificationType = ModificationType.Mod, + Name = "Installed", + Version = "1.0" + }); + launcherData.Modifications[0].IsSelected = false; + + var state = LauncherContentStateMapper.ToLauncherContentState(launcherData); + + LauncherContentEntryState entry = state.Modifications.Should().ContainSingle().Subject; + entry.IsSelected.Should().BeFalse(); + entry.ModificationVersions.Should().ContainSingle().Which.IsSelected.Should().BeFalse(); + } + + [Fact] + public void ToLauncherContentState_PersistsEntryOrder() + { + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(new LauncherContentVersion + { + Installation = new LauncherContentInstallation { Installed = true }, + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.0" + }); + launcherData.Modifications[0].NumberInList = 7; + + var state = LauncherContentStateMapper.ToLauncherContentState(launcherData); + + state.Modifications.Should().ContainSingle().Which.NumberInList.Should().Be(7); + } + + /// + /// A stored document can carry an explicit null for a whole section. That reads as "nothing stored here", not + /// as a reason to fail the restore and lose every other section with it. + /// + [Fact] + public void ToLauncherData_MapsNullStoredSectionsToAnEmptyCatalog() + { + var state = new LauncherContentState + { + Modifications = null!, + Addons = null!, + Patches = null! + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + launcherData.Modifications.Should().BeEmpty(); + launcherData.Patches.Should().BeEmpty(); + launcherData.Addons.Should().BeEmpty(); + } + + [Fact] + public void ToLauncherData_MapsAnEntryWithNullVersionRecordsToNoContent() + { + var state = new LauncherContentState + { + Modifications = + [ + new() + { + Name = "ShockWave", + ModificationType = ModificationType.Mod, + ModificationVersions = null! + } + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + launcherData.Modifications.Should().BeEmpty(); + } + + /// + /// Legacy documents mix advertising records in among ordinary version records. Such a record maps to no + /// content card at all, and that must not cost the entry the order and selection it stored. + /// + [Fact] + public void ToLauncherData_KeepsStoredEntryOrderWhenALegacyAdvertisingRecordFollowsTheContentRecord() + { + var state = new LauncherContentState + { + Modifications = + [ + new() + { + Name = "ShockWave", + ModificationType = ModificationType.Mod, + IsSelected = true, + NumberInList = 7, + ModificationVersions = + [ + new() { Version = "1.0", Installed = true, IsSelected = true }, + new() + { + Name = "Featured", + Version = "2.0", + ModificationType = ModificationType.Advertising, + Installed = true + } + ] + } + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + LauncherContent modification = launcherData.Modifications.Should().ContainSingle().Subject; + modification.NumberInList.Should().Be(7); + modification.IsSelected.Should().BeTrue(); + } + + /// + /// A version record carries its own identity, and it is the record each version is rebuilt from, so the name + /// and parent it declares win over the entry header it happens to be stored under. + /// + [Fact] + public void ToLauncherData_PrefersTheVersionRecordIdentityOverTheEntryHeader() + { + var state = new LauncherContentState + { + Patches = + [ + new() + { + Name = "Stale Entry Name", + DependenceName = "Stale Parent", + ModificationType = ModificationType.Patch, + ModificationVersions = + [ + new() + { + Name = "Balance Patch", + Version = "2.0", + DependenceName = "ShockWave", + Installed = true + } + ] + } + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + LauncherContentVersion version = launcherData.Patches.Should().ContainSingle().Subject + .Versions.Should().ContainSingle().Subject; + version.Name.Should().Be("Balance Patch"); + version.ParentContentName.Should().Be("ShockWave"); + } + + /// + /// Any stored text key may be an explicit null. Identity text is what the catalog keys content by, so a null + /// has to restore as empty text rather than travel into a content key. + /// + [Fact] + public void ToLauncherData_MapsNullStoredIdentityTextToEmptyValues() + { + var state = new LauncherContentState + { + Modifications = + [ + new() + { + Name = null!, + DependenceName = null!, + ModificationType = ModificationType.Mod, + ModificationVersions = [new() { Version = null!, Installed = true }] + } + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + LauncherContentVersion version = launcherData.Modifications.Should().ContainSingle().Subject + .Versions.Should().ContainSingle().Subject; + version.Name.Should().BeEmpty(); + version.Version.Should().BeEmpty(); + version.ParentContentName.Should().BeEmpty(); + } + + /// + /// A record that never stored a version restores under the version-less identity, which is the identity the + /// mod folder itself is keyed by. + /// + [Fact] + public void ToLauncherData_RestoresARecordWithNoStoredVersionUnderTheVersionlessIdentity() + { + var state = new LauncherContentState + { + Modifications = + [ + new() + { + Name = "ShockWave", + ModificationType = ModificationType.Mod, + ModificationVersions = [new() { Installed = true }] + } + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + launcherData.Modifications.Should().ContainSingle().Subject + .Versions.Should().ContainSingle().Subject + .Version.Should().BeEmpty(); + } + + /// + /// Content type is settled by the version record first, then the entry header, then the section the entry is + /// stored in. A record that declares a type of its own therefore keeps it even when the section disagrees. + /// + [Fact] + public void ToLauncherData_UsesTheVersionRecordTypeWhenItDisagreesWithTheStoredSection() + { + var state = new LauncherContentState + { + Patches = + [ + new() + { + Name = "HD Textures", + DependenceName = "ShockWave", + ModificationType = ModificationType.Patch, + ModificationVersions = + [ + new() { Version = "1.0", ModificationType = ModificationType.Addon, Installed = true } + ] + } + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + launcherData.Patches.Should().BeEmpty(); + launcherData.Addons.Should().ContainSingle().Subject + .Versions.Should().ContainSingle().Subject + .ModificationType.Should().Be(ModificationType.Addon); + } + + /// + /// When the version record declares no type of its own, the entry header settles it, and the header still + /// outranks the section the entry is stored in. + /// + [Fact] + public void ToLauncherData_UsesTheEntryHeaderTypeWhenTheVersionRecordDeclaresNoneAndTheSectionDisagrees() + { + var state = new LauncherContentState + { + Patches = + [ + new() + { + Name = "HD Textures", + DependenceName = "ShockWave", + ModificationType = ModificationType.Addon, + ModificationVersions = [new() { Version = "1.0", Installed = true }] + } + ] + }; + + var launcherData = LauncherContentStateMapper.ToLauncherData(state); + + launcherData.Patches.Should().BeEmpty(); + launcherData.Addons.Should().ContainSingle().Subject + .Versions.Should().ContainSingle().Subject + .ModificationType.Should().Be(ModificationType.Addon); + } + + /// + /// Each persisted version record carries its own identity and type, because that record — not the entry + /// header — is what the next start rebuilds the version from. + /// + [Fact] + public void ToLauncherContentState_PersistsEachVersionRecordWithItsOwnIdentityAndType() + { + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(TestLauncherContent.Version( + "Balance Patch", + "2.0", + ModificationType.Patch, + "ShockWave", + installed: true)); + + var state = LauncherContentStateMapper.ToLauncherContentState(launcherData); + + LauncherContentEntryState entry = state.Patches.Should().ContainSingle().Subject; + entry.ModificationType.Should().Be(ModificationType.Patch); + LauncherContentVersionState version = entry.ModificationVersions.Should().ContainSingle().Subject; + version.Name.Should().Be("Balance Patch"); + version.Version.Should().Be("2.0"); + version.DependenceName.Should().Be("ShockWave"); + version.ModificationType.Should().Be(ModificationType.Patch); + } + + private static LauncherContentEntryState CreateEntry( + string name, + string parentContentName, + ModificationType contentType, + string version, + bool selected, + bool? versionSelected = null, + int numberInList = 4) + { + return new LauncherContentEntryState + { + Name = name, + DependenceName = parentContentName, + ModificationType = contentType, + IsSelected = selected, + NumberInList = numberInList, + ModificationVersions = + [ + new() + { + 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..56d32a1d --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/LauncherLocalContentReconcilerTests.cs @@ -0,0 +1,271 @@ +using System.Collections; +using System.Collections.Generic; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Contracts; +using GenLauncherGO.Infrastructure.Mods.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class LauncherLocalContentReconcilerTests +{ + [Fact] + public void Reconcile_AddsUnregisteredLocalVersions() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var localContentService = new RecordingLocalLauncherContentService(); + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + var launcherData = new LauncherData(); + localContentService.InstalledVersions = + [ + TestLauncherContent.Version("Local Only", "1.0", installed: true) + ]; + + reconciler.Reconcile(launcherData, new List(), paths); + + launcherData.Modifications.Should().ContainSingle(mod => mod.Name == "Local Only"); + localContentService.EmptyPackageBackupCleanupRequests.Should().ContainSingle().Which.Should().Be(paths); + } + + [Fact] + public void Reconcile_MarksMissingRemoteVersionsUninstalledAndDeletesMissingLocalOnlyVersions() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var localContentService = new RecordingLocalLauncherContentService(); + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + LauncherContentVersion remoteVersion = TestLauncherContent.Version("Remote", "1.0", installed: true); + LauncherContentVersion localOnlyVersion = TestLauncherContent.Version("Local Only", "2.0", installed: true); + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(remoteVersion); + launcherData.AddOrUpdate(localOnlyVersion); + localContentService.InstalledVersions = []; + + reconciler.Reconcile( + launcherData, + new List { remoteVersion.ContentKey }, + paths); + + launcherData.Modifications.Should().ContainSingle().Which.Name.Should().Be("Remote"); + launcherData.Modifications[0].Versions.Should().ContainSingle().Which.Installation.Installed.Should().BeFalse(); + localContentService.ImageDeletionRequests.Should().ContainSingle(request => + request.Paths == paths && + request.ContentKey.Name == "Local Only" && + ReferenceEquals(request.Data, launcherData)); + } + + [Fact] + public void Reconcile_PreservesAddedRepositoryModWhenRemoteCatalogIsUnavailable() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var localContentService = new RecordingLocalLauncherContentService + { + InstalledVersions = [] + }; + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + LauncherContentVersion addedVersion = TestLauncherContent.Version( + "Added", + "1.0", + sourceKind: ContentSourceKind.ManagedSingleFile); + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(addedVersion); + + reconciler.Reconcile(launcherData, [], paths); + + launcherData.Modifications.Should().ContainSingle() + .Which.Name.Should().Be("Added"); + localContentService.ImageDeletionRequests.Should().BeEmpty(); + } + + [Fact] + public void Reconcile_MarksStaleOriginalGameAddonUninstalled() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var localContentService = new RecordingLocalLauncherContentService(); + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + LauncherContentVersion addon = TestLauncherContent.Version( + "Original Game Addon", + "1.0", + ModificationType.Addon, + LauncherContentKey.OriginalGame.Name, + installed: true); + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(addon); + + reconciler.Reconcile(launcherData, new[] { addon.ContentKey }, paths); + + launcherData.Addons.Should().ContainSingle(); + addon.Installation.Installed.Should().BeFalse(); + } + + [Fact] + public void Reconcile_MarksStaleOriginalGamePatchUninstalled() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var localContentService = new RecordingLocalLauncherContentService(); + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + LauncherContentVersion patch = TestLauncherContent.Version( + "Original Game Patch", + "1.0", + ModificationType.Patch, + LauncherContentKey.OriginalGame.Name, + installed: true); + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(patch); + + reconciler.Reconcile(launcherData, new[] { patch.ContentKey }, paths); + + launcherData.Patches.Should().ContainSingle(); + patch.Installation.Installed.Should().BeFalse(); + } + + [Fact] + public void Reconcile_ChecksAChildSharedByMultipleParentVersionsOnce() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var localContentService = new RecordingLocalLauncherContentService(); + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + LauncherContentVersion firstParent = TestLauncherContent.Version("Parent", "1.0"); + LauncherContentVersion secondParent = TestLauncherContent.Version("Parent", "2.0"); + LauncherContentVersion child = TestLauncherContent.Version( + "Shared Child", + "1.0", + ModificationType.Addon, + "Parent", + installed: true); + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(firstParent); + launcherData.AddOrUpdate(secondParent); + launcherData.AddOrUpdate(child); + localContentService.InstalledVersions = [firstParent, secondParent]; + var downloadedContent = + new EnumerationCountingReadOnlyCollection([child.ContentKey]); + + reconciler.Reconcile(launcherData, downloadedContent, paths); + + child.Installation.Installed.Should().BeFalse(); + downloadedContent.EnumerationCount.Should().Be(1); + } + + [Theory] + [InlineData("First Patch")] + [InlineData("Second Patch")] + public void Reconcile_IsIndependentOfTheGloballySelectedPatch(string selectedPatchName) + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var localContentService = new RecordingLocalLauncherContentService(); + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + LauncherContentVersion parent = TestLauncherContent.Version("Parent", "1.0", isSelected: true); + LauncherContentVersion firstPatch = TestLauncherContent.Version( + "First Patch", + "1.0", + ModificationType.Patch, + parent.Name, + isSelected: selectedPatchName == "First Patch"); + LauncherContentVersion secondPatch = TestLauncherContent.Version( + "Second Patch", + "1.0", + ModificationType.Patch, + parent.Name, + isSelected: selectedPatchName == "Second Patch"); + LauncherContentVersion firstAddon = TestLauncherContent.Version( + "First Addon", + "1.0", + ModificationType.Addon, + firstPatch.Name, + installed: true); + LauncherContentVersion secondAddon = TestLauncherContent.Version( + "Second Addon", + "1.0", + ModificationType.Addon, + secondPatch.Name, + installed: true); + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(parent); + launcherData.AddOrUpdate(firstPatch); + launcherData.AddOrUpdate(secondPatch); + launcherData.AddOrUpdate(firstAddon); + launcherData.AddOrUpdate(secondAddon); + localContentService.InstalledVersions = [parent, firstPatch, secondPatch]; + + reconciler.Reconcile( + launcherData, + new[] { firstAddon.ContentKey, secondAddon.ContentKey }, + paths); + + firstAddon.Installation.Installed.Should().BeFalse(); + secondAddon.Installation.Installed.Should().BeFalse(); + } + + /// + /// Discarding a version drops it from the catalog as well as from disk, and asks for its cached artwork to be + /// removed once nothing else refers to the card. A version the user threw away must not come back as a card + /// entry the next time the list is drawn. + /// + [Fact] + public void DiscardVersion_RemovesTheVersionFromTheCatalogAndFromDisk() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory.Path); + var localContentService = new RecordingLocalLauncherContentService(); + LauncherLocalContentReconciler reconciler = CreateReconciler(localContentService); + LauncherContentVersion discardedVersion = TestLauncherContent.Version("ShockWave", "1.0", installed: true); + LauncherContentVersion keptVersion = TestLauncherContent.Version("ShockWave", "2.0", installed: true); + var launcherData = new LauncherData(); + launcherData.AddOrUpdate(discardedVersion); + launcherData.AddOrUpdate(keptVersion); + + reconciler.DiscardVersion(launcherData, discardedVersion.ContentKey, paths); + + launcherData.Modifications.Should().ContainSingle() + .Which.Versions.Should().ContainSingle() + .Which.Version.Should().Be("2.0"); + localContentService.DeletedVersions.Should().ContainSingle(request => + request.Paths == paths && + request.ContentKey == discardedVersion.ContentKey); + localContentService.ImageDeletionRequests.Should().ContainSingle(request => + request.ContentKey == discardedVersion.ContentKey && + ReferenceEquals(request.Data, launcherData)); + } + + private static LauncherLocalContentReconciler CreateReconciler( + ILocalLauncherContentService localContentService) + { + return new LauncherLocalContentReconciler( + localContentService, + NullLogger.Instance); + } + + private sealed class EnumerationCountingReadOnlyCollection : IReadOnlyCollection + { + private readonly IReadOnlyCollection _items; + + public EnumerationCountingReadOnlyCollection(IReadOnlyCollection items) + { + _items = items; + } + + public int EnumerationCount { get; private set; } + + public int Count => _items.Count; + + public IEnumerator GetEnumerator() + { + EnumerationCount++; + return _items.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/RemoteLauncherCatalogClientTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/RemoteLauncherCatalogClientTests.cs new file mode 100644 index 00000000..ea71506e --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/RemoteLauncherCatalogClientTests.cs @@ -0,0 +1,418 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +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 DownloadInstalledModDataAsync_ReadsInstalledModsAndPreservesPartialFailuresAsync() + { + var yamlReader = new StubRemoteYamlDocumentReader(); + 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()); + yamlReader.SetResult(shockwaveUri, new LegacyContentManifest + { + Name = "ShockWave", + Version = "1.2" + }); + yamlReader.SetException( + brokenUri, + new InvalidOperationException("Broken manifest")); + + IReadOnlyList result = + await client.DownloadInstalledModDataAsync( + catalog, + new[] { "shockwave", "broken" }, + CancellationToken.None); + + RemoteModificationManifest entry = result.Should().ContainSingle().Subject; + entry.Content.Name.Should().Be("ShockWave"); + entry.Content.Version.Should().Be("1.2"); + entry.PatchManifestUrls.Should().BeEmpty(); + yamlReader.GetReadCount(contraUri).Should().Be(0); + } + + /// + /// The backend publishes unnamed references for content the launcher cannot match against installed folders, + /// so they are read on every refresh instead of being filtered out with the mods nobody installed. + /// + [Fact] + public async Task DownloadInstalledModDataAsync_ReadsUnnamedReferenceRegardlessOfInstalledContentAsync() + { + var yamlReader = new StubRemoteYamlDocumentReader(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var unnamedUri = new Uri("https://example.test/unnamed.yaml"); + var contraUri = new Uri("https://example.test/contra.yaml"); + var catalog = new RemoteLauncherCatalog( + Array.Empty(), + new List + { + new(string.Empty, unnamedUri.ToString(), Array.Empty(), Array.Empty()), + new("Contra", contraUri.ToString(), Array.Empty(), Array.Empty()) + }, + Array.Empty(), + Array.Empty()); + yamlReader.SetResult(unnamedUri, new LegacyContentManifest + { + Name = "Community Pack", + Version = "3.0" + }); + + IReadOnlyList result = + await client.DownloadInstalledModDataAsync( + catalog, + new[] { "ShockWave" }, + CancellationToken.None); + + result.Should().ContainSingle().Which.Content.Name.Should().Be("Community Pack"); + yamlReader.GetReadCount(contraUri).Should().Be(0); + } + + [Fact] + public async Task ReadChildManifestsAsync_ReturnsSuccessfulChildrenWhenOneChildFailsAsync() + { + var yamlReader = new StubRemoteYamlDocumentReader(); + 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.SetResult(patchUri, new LegacyContentManifest + { + Name = "Patch", + Version = "1.0" + }); + yamlReader.SetException( + missingUri, + new InvalidOperationException("Missing manifest")); + + RemoteChildManifestLoadResult result = await client.ReadChildManifestsAsync( + new[] { patchUri.ToString(), missingUri.ToString() }, + null, + CancellationToken.None); + + result.ContentVersions.Should().ContainSingle().Which.Name.Should().Be("Patch"); + result.FailedCount.Should().Be(1); + result.Succeeded.Should().BeFalse(); + } + + [Fact] + public async Task ReadCatalogAsync_MapsThirdPartyManifestToNormalizedCatalogAsync() + { + var yamlReader = new StubRemoteYamlDocumentReader(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var manifestUri = new Uri("https://example.test/repos.yaml"); + yamlReader.SetResult(manifestUri, new LegacyLauncherCatalogDocument + { + AdvData = + { + new LegacyCatalogAdvertisingReference + { + ModName = "Featured", + ModLink = "https://example.test/featured.yaml", + ImagesData = { "https://cdn.example.test/featured.png" } + } + }, + modDatas = + { + new LegacyCatalogModificationReference + { + 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" + }); + + RemoteLauncherCatalog catalog = await client.ReadCatalogAsync(manifestUri, CancellationToken.None); + + 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"); + } + + [Fact] + public void GetModificationNames_ReturnsCatalogModificationNames() + { + var yamlReader = new StubRemoteYamlDocumentReader(); + 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()); + + IReadOnlyList names = client.GetModificationNames(catalog); + + names.Should().Equal("ShockWave", "Contra"); + } + + [Fact] + public async Task DownloadModDataByNameAsync_ReadsReferenceCaseInsensitivelyAsync() + { + var yamlReader = new StubRemoteYamlDocumentReader(); + 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()); + yamlReader.SetResult(shockwaveUri, new LegacyContentManifest + { + Name = "ShockWave", + Version = "1.2" + }); + + RemoteModificationManifest result = await client.DownloadModDataByNameAsync( + catalog, + "shockwave", + CancellationToken.None); + + 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 DownloadAdvertisingInfoAsync_ReturnsManifestWhenReadSucceedsAsync() + { + var yamlReader = new StubRemoteYamlDocumentReader(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var manifestUri = new Uri("https://example.test/featured.yaml"); + yamlReader.SetResult(manifestUri, new LegacyContentManifest + { + Name = "Featured", + Version = "2.0", + UIImageSourceLink = "https://cdn.example.test/featured.png" + }); + + LauncherContentVersion? result = await client.DownloadAdvertisingInfoAsync( + manifestUri.ToString(), + CancellationToken.None); + + result.Should().NotBeNull(); + result!.Name.Should().Be("Featured"); + result.Version.Should().Be("2.0"); + result.UIImageSourceLink.Should().Be("https://cdn.example.test/featured.png"); + } + + [Fact] + public async Task DownloadAdvertisingInfoAsync_ReturnsNullWhenReadFailsAsync() + { + var yamlReader = new StubRemoteYamlDocumentReader(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var manifestUri = new Uri("https://example.test/featured.yaml"); + yamlReader.SetException( + manifestUri, + new InvalidOperationException("Missing manifest.")); + + LauncherContentVersion? result = await client.DownloadAdvertisingInfoAsync( + manifestUri.ToString(), + CancellationToken.None); + + result.Should().BeNull(); + } + + /// + /// A manifest read that fails is tolerated and reported as absent content, but a cancelled one is not a + /// failure of the manifest: swallowing it would let a torn-down session go on to publish a catalog assembled + /// from whatever happened to arrive first. + /// + [Fact] + public async Task DownloadInstalledModDataAsync_PropagatesCancellationRaisedWhileReadingAsync() + { + var yamlReader = new StubRemoteYamlDocumentReader(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var shockwaveUri = new Uri("https://example.test/shockwave.yaml"); + var catalog = new RemoteLauncherCatalog( + Array.Empty(), + new List + { + new("ShockWave", shockwaveUri.ToString(), Array.Empty(), Array.Empty()) + }, + Array.Empty(), + Array.Empty()); + using var cancellation = new CancellationTokenSource(); + yamlReader.SetHandler(shockwaveUri, (_, _) => + { + cancellation.Cancel(); + return Task.FromCanceled(cancellation.Token); + }); + + Func act = () => client.DownloadInstalledModDataAsync( + catalog, + new[] { "ShockWave" }, + cancellation.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task ReadChildManifestsAsync_PropagatesCancellationRaisedWhileReadingAsync() + { + var yamlReader = new StubRemoteYamlDocumentReader(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var patchUri = new Uri("https://example.test/patch.yaml"); + using var cancellation = new CancellationTokenSource(); + yamlReader.SetHandler(patchUri, (_, _) => + { + cancellation.Cancel(); + return Task.FromCanceled(cancellation.Token); + }); + + Func act = () => client.ReadChildManifestsAsync( + new[] { patchUri.ToString() }, + null, + cancellation.Token); + + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DownloadAdvertisingInfoAsync_PropagatesCancellationRaisedWhileReadingAsync() + { + var yamlReader = new StubRemoteYamlDocumentReader(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var manifestUri = new Uri("https://example.test/featured.yaml"); + using var cancellation = new CancellationTokenSource(); + yamlReader.SetHandler(manifestUri, (_, _) => + { + cancellation.Cancel(); + return Task.FromCanceled(cancellation.Token); + }); + + Func act = () => client.DownloadAdvertisingInfoAsync( + manifestUri.ToString(), + cancellation.Token); + + await act.Should().ThrowAsync(); + } + + /// + /// Manifest reads are capped at a handful in flight so a refresh does not flood the backend. The cap bounds + /// how many run at once, not how many are read: a catalog listing more than the cap still gets read whole. + /// + [Fact] + public async Task ReadChildManifestsAsync_ReadsEveryManifestBeyondItsConcurrencyLimitAsync() + { + var yamlReader = new StubRemoteYamlDocumentReader(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var manifestUrls = new List(); + for (int index = 0; index < 12; index++) + { + var childUri = new Uri($"https://example.test/patch-{index}.yaml"); + yamlReader.SetResult(childUri, new LegacyContentManifest + { + ModificationType = ModificationType.Patch, + Name = $"Patch {index}", + Version = "1.0" + }); + manifestUrls.Add(childUri.ToString()); + } + + RemoteChildManifestLoadResult result = await client.ReadChildManifestsAsync( + manifestUrls, + "ShockWave", + CancellationToken.None); + + result.ContentVersions.Should().HaveCount(12); + result.Succeeded.Should().BeTrue(); + } + + [Fact] + public async Task DownloadInstalledModDataAsync_ReadsEveryManifestBeyondItsConcurrencyLimitAsync() + { + var yamlReader = new StubRemoteYamlDocumentReader(); + var client = new RemoteLauncherCatalogClient( + yamlReader, + NullLogger.Instance); + var references = new List(); + var installedModNames = new List(); + for (int index = 0; index < 12; index++) + { + var modUri = new Uri($"https://example.test/mod-{index}.yaml"); + yamlReader.SetResult(modUri, new LegacyContentManifest + { + ModificationType = ModificationType.Mod, + Name = $"Mod {index}", + Version = "1.0" + }); + references.Add(new RemoteCatalogModificationReference( + $"Mod {index}", + modUri.ToString(), + Array.Empty(), + Array.Empty())); + installedModNames.Add($"Mod {index}"); + } + + IReadOnlyList result = await client.DownloadInstalledModDataAsync( + new RemoteLauncherCatalog( + Array.Empty(), + references, + Array.Empty(), + Array.Empty()), + installedModNames, + CancellationToken.None); + + result.Should().HaveCount(12); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Services/YamlLauncherContentStateStoreTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Services/YamlLauncherContentStateStoreTests.cs new file mode 100644 index 00000000..8bdcbe96 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Services/YamlLauncherContentStateStoreTests.cs @@ -0,0 +1,242 @@ +using System.IO; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Services; +using GenLauncherGO.Infrastructure.Persistence.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Services; + +public sealed class YamlLauncherContentStateStoreTests +{ + [Theory] + [InlineData("Mod", 0)] + [InlineData("Addon", 1)] + [InlineData("Patch", 2)] + [InlineData("Advertising", 3)] + public void Load_AcceptsLegacyPersistedContentTypeNames( + string persistedValue, + int expectedTypeValue) + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string documentPath = paths.LauncherDataFilePath; + Directory.CreateDirectory(Path.GetDirectoryName(documentPath)!); + File.WriteAllText( + documentPath, + $""" + Addons: [] + Modifications: + - ModificationType: {persistedValue} + Name: Compatibility Entry + DependenceName: Original Game + Installed: true + IsSelected: false + NumberInList: 0 + ModificationVersions: + - ModificationType: {persistedValue} + Name: Compatibility Entry + Version: 1.0 + DependenceName: Original Game + Installed: true + IsSelected: false + ContentSourceKind: Manual + Patches: [] + """); + var store = new YamlLauncherContentStateStore( + new AtomicFileWriter(), + NullLogger>.Instance); + + LauncherContentState state = store.Load(paths); + + LauncherContentEntryState entry = state.Modifications.Should().ContainSingle().Subject; + entry.ModificationType.Should().Be((ModificationType)expectedTypeValue); + entry.DependenceName.Should().Be("Original Game"); + LauncherContentVersionState version = entry.ModificationVersions.Should().ContainSingle().Subject; + version.ModificationType.Should().Be((ModificationType)expectedTypeValue); + version.Name.Should().Be("Compatibility Entry"); + version.Version.Should().Be("1.0"); + version.DependenceName.Should().Be("Original Game"); + } + + [Theory] + [InlineData(0, "Mod")] + [InlineData(1, "Addon")] + [InlineData(2, "Patch")] + [InlineData(3, "Advertising")] + public void Save_PreservesLegacyPersistedContentTypeNames( + int contentTypeValue, + string persistedValue) + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string documentPath = paths.LauncherDataFilePath; + var store = new YamlLauncherContentStateStore( + new AtomicFileWriter(), + NullLogger>.Instance); + var state = new LauncherContentState + { + Modifications = + { + new LauncherContentEntryState + { + ModificationType = (ModificationType)contentTypeValue, + Name = "Compatibility Entry", + DependenceName = "Original Game", + ModificationVersions = + { + new LauncherContentVersionState + { + ModificationType = (ModificationType)contentTypeValue, + Name = "Compatibility Entry", + Version = "1.0", + DependenceName = "Original Game" + } + } + } + } + }; + + store.Save(paths, state); + + string yaml = File.ReadAllText(documentPath); + yaml.Should().Contain($"ModificationType: {persistedValue}", Exactly.Twice()); + yaml.Should().Contain("DependenceName: Original Game", Exactly.Twice()); + } + + [Fact] + public void Load_UsesEmptyContentStateAsDefaultDocument() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + var store = new YamlLauncherContentStateStore( + new AtomicFileWriter(), + NullLogger>.Instance); + + LauncherContentState state = store.Load(paths); + + state.Modifications.Should().BeEmpty(); + state.Addons.Should().BeEmpty(); + state.Patches.Should().BeEmpty(); + } + + [Fact] + public void Save_KeepsIdenticalContentKeysIsolatedByGame() + { + using var directory = new TestDirectory(); + LauncherPaths generalsPaths = TestLauncherPaths.Create(directory, SupportedGame.Generals); + LauncherPaths zeroHourPaths = TestLauncherPaths.Create(directory); + var store = new YamlLauncherContentStateStore( + new AtomicFileWriter(), + NullLogger>.Instance); + LauncherContentState generalsState = CreateState("Shared Key", "Generals Version"); + LauncherContentState zeroHourState = CreateState("Shared Key", "Zero Hour Version"); + + store.Save(generalsPaths, generalsState); + store.Save(zeroHourPaths, zeroHourState); + + store.Load(generalsPaths).Modifications.Should().ContainSingle() + .Which.ModificationVersions.Should().ContainSingle() + .Which.Version.Should().Be("Generals Version"); + store.Load(zeroHourPaths).Modifications.Should().ContainSingle() + .Which.ModificationVersions.Should().ContainSingle() + .Which.Version.Should().Be("Zero Hour Version"); + generalsPaths.LauncherDataFilePath.Should().NotBe(zeroHourPaths.LauncherDataFilePath); + } + + [Fact] + public void Save_PreservesLauncherContentYamlKeysAndEnumValues() + { + using var directory = new TestDirectory(); + LauncherPaths paths = TestLauncherPaths.Create(directory); + string documentPath = paths.LauncherDataFilePath; + var store = new YamlLauncherContentStateStore( + new AtomicFileWriter(), + NullLogger>.Instance); + var state = new LauncherContentState + { + Modifications = + { + new LauncherContentEntryState + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + DependenceName = "Original game", + Installed = true, + IsSelected = true, + NumberInList = 3, + ModificationVersions = + { + new LauncherContentVersionState + { + ModificationType = ModificationType.Mod, + Name = "ShockWave", + Version = "1.2", + DependenceName = "Original game", + Installed = true, + IsSelected = true, + DownloadSuspended = true, + SuspendedProgressPercentage = 42.5, + ContentSourceKind = ContentSourceKind.Manual + } + } + } + } + }; + + store.Save(paths, state); + + string yaml = File.ReadAllText(documentPath); + yaml.Should().Contain("Addons:"); + yaml.Should().Contain("Modifications:"); + yaml.Should().Contain("Patches:"); + yaml.Should().Contain("ModificationType: Mod"); + yaml.Should().Contain("Name: ShockWave"); + yaml.Should().Contain("Version: 1.2"); + yaml.Should().Contain("DependenceName: Original game"); + yaml.Should().Contain("Installed: true"); + yaml.Should().Contain("IsSelected: true"); + yaml.Should().Contain("NumberInList: 3"); + yaml.Should().Contain("ModificationVersions:"); + yaml.Should().Contain("DownloadSuspended: true"); + yaml.Should().Contain("SuspendedProgressPercentage: 42.5"); + yaml.Should().Contain("ContentSourceKind: Manual"); + + LauncherContentState loadedState = store.Load(paths); + LauncherContentEntryState loadedEntry = loadedState.Modifications.Should().ContainSingle().Subject; + loadedEntry.ModificationType.Should().Be(ModificationType.Mod); + loadedEntry.NumberInList.Should().Be(3); + LauncherContentVersionState loadedVersion = + loadedEntry.ModificationVersions.Should().ContainSingle().Subject; + loadedVersion.Version.Should().Be("1.2"); + loadedVersion.DownloadSuspended.Should().BeTrue(); + loadedVersion.SuspendedProgressPercentage.Should().Be(42.5); + loadedVersion.ContentSourceKind.Should().Be(ContentSourceKind.Manual); + } + + private static LauncherContentState CreateState(string name, string version) + { + return new LauncherContentState + { + Modifications = + { + new LauncherContentEntryState + { + ModificationType = ModificationType.Mod, + Name = name, + ModificationVersions = + { + new LauncherContentVersionState + { + ModificationType = ModificationType.Mod, + Name = name, + Version = version + } + } + } + } + }; + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Mods/Support/RemoteLauncherCatalogMapperTests.cs b/GenLauncherGO.Tests/Infrastructure/Mods/Support/RemoteLauncherCatalogMapperTests.cs new file mode 100644 index 00000000..38c80674 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Mods/Support/RemoteLauncherCatalogMapperTests.cs @@ -0,0 +1,420 @@ +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Models; +using GenLauncherGO.Infrastructure.Mods.Support; +using GenLauncherGO.Infrastructure.Remote; + +namespace GenLauncherGO.Tests.Infrastructure.Mods.Support; + +public sealed class RemoteLauncherCatalogMapperTests +{ + [Fact] + public async Task PublishedCatalogYaml_MapsExactLegacyShapeAndLeavesVestigialGlobalAddonsUnpublishedAsync() + { + const string GlobalAddonUrl = "https://example.test/global-addon.yaml"; + LegacyLauncherCatalogDocument document = await ReadRemoteYamlAsync( + """ + AdvData: + - ModName: Featured + ModLink: https://example.test/featured.yaml + ImagesData: + - https://cdn.example.test/featured-1.png + - https://cdn.example.test/featured-2.png + modDatas: + - ModName: ShockWave + ModLink: https://example.test/shockwave.yaml + ModPatches: + - https://example.test/shockwave-patch.yaml + - ~ + ModAddons: + - https://example.test/shockwave-addon.yaml + - ModName: Contra + ModLink: https://example.test/contra.yaml + globalAddonsData: + - https://example.test/global-addon.yaml + originalGameAddons: + - https://example.test/original-addon.yaml + - + originalGamePatches: + - https://example.test/original-patch.yaml + LauncherVersion: 1.2.3 + """); + + document.globalAddonsData.Should().Equal(GlobalAddonUrl); + document.modDatas.Should().HaveCount(2); + document.modDatas[0].ModPatches.Should().HaveCount(2) + .And.Contain(url => url == null); + document.originalGameAddons.Should().HaveCount(2) + .And.Contain(url => url == null); + LegacyCatalogModificationReference defaultedReference = document.modDatas[1]; + defaultedReference.ModPatches.Should().BeEmpty(); + defaultedReference.ModAddons.Should().BeEmpty(); + + RemoteLauncherCatalog catalog = RemoteLauncherCatalogMapper.ToRemoteCatalog(document); + + RemoteAdvertisingReference advertising = catalog.AdvertisingEntries.Should().ContainSingle().Subject; + advertising.Name.Should().Be("Featured"); + advertising.ManifestUrl.Should().Be("https://example.test/featured.yaml"); + advertising.ImageUrls.Should().Equal( + "https://cdn.example.test/featured-1.png", + "https://cdn.example.test/featured-2.png"); + + RemoteCatalogModificationReference modification = catalog.Modifications[0]; + modification.Name.Should().Be("ShockWave"); + modification.ManifestUrl.Should().Be("https://example.test/shockwave.yaml"); + modification.PatchManifestUrls.Should().Equal("https://example.test/shockwave-patch.yaml"); + modification.AddonManifestUrls.Should().Equal("https://example.test/shockwave-addon.yaml"); + catalog.Modifications[1].Name.Should().Be("Contra"); + catalog.Modifications[1].PatchManifestUrls.Should().BeEmpty(); + catalog.Modifications[1].AddonManifestUrls.Should().BeEmpty(); + catalog.OriginalGameAddonManifestUrls.Should().Equal( + "https://example.test/original-addon.yaml"); + catalog.OriginalGamePatchManifestUrls.Should().Equal( + "https://example.test/original-patch.yaml"); + + catalog.Modifications.SelectMany(entry => entry.AddonManifestUrls) + .Should().NotContain(GlobalAddonUrl); + catalog.OriginalGameAddonManifestUrls.Should().NotContain(GlobalAddonUrl); + } + + [Fact] + public async Task PublishedContentYaml_MapsSupportedFieldsAndThemeBlockAsync() + { + LegacyContentManifest document = await ReadRemoteYamlAsync( + """ + ModificationType: Patch + Name: ShockWave Patch + Version: '2.4' + SimpleDownloadLink: https://downloads.example.test/shockwave-patch.zip + UIImageSourceLink: https://cdn.example.test/shockwave-patch.png + DiscordLink: https://discord.example.test/shockwave + ModDBLink: https://moddb.example.test/shockwave + NewsLink: https://news.example.test/shockwave + DependenceName: ShockWave + S3HostLink: https://s3.example.test + S3BucketName: launcher-content + S3FolderName: shockwave/patch + S3HostPublicKey: public-key + S3HostSecretKey: secret-key + NetworkInfo: Multiplayer requires the community service. + Deprecated: true + SupportLink: https://support.example.test/shockwave + ColorsInformation: + GenLauncherActiveColor: '#102030' + GenLauncherBackgroundImageLink: https://cdn.example.test/background.png + ContentSourceKind: Manual + """); + + var version = RemoteLauncherCatalogMapper.ToLauncherContentVersion(document); + + version.Should().BeEquivalentTo(new LauncherContentVersion + { + Installation = new LauncherContentInstallation { ContentSourceKind = ContentSourceKind.ManagedS3 }, + ModificationType = ModificationType.Patch, + Name = "ShockWave Patch", + Version = "2.4", + SimpleDownloadLink = "https://downloads.example.test/shockwave-patch.zip", + UIImageSourceLink = "https://cdn.example.test/shockwave-patch.png", + DiscordLink = "https://discord.example.test/shockwave", + ModDBLink = "https://moddb.example.test/shockwave", + NewsLink = "https://news.example.test/shockwave", + ParentContentName = "ShockWave", + S3HostLink = "https://s3.example.test", + S3BucketName = "launcher-content", + S3FolderName = "shockwave/patch", + S3HostPublicKey = "public-key", + S3HostSecretKey = "secret-key", + NetworkInfo = "Multiplayer requires the community service.", + Deprecated = true, + SupportLink = "https://support.example.test/shockwave", + Theme = new LauncherContentTheme + { + GenLauncherActiveColor = "#102030", + GenLauncherBackgroundImageLink = "https://cdn.example.test/background.png" + } + }); + } + + /// + /// The backend publishes a key with an explicit null whenever it has nothing for it. Every consumer of a + /// mapped version reads these as plain text, so a published null has to arrive as empty text and never as a + /// null that would fault the first caller to touch it. + /// + [Fact] + public async Task PublishedContentYaml_MapsExplicitlyNullTextKeysToEmptyValuesAsync() + { + LegacyContentManifest document = await ReadRemoteYamlAsync( + """ + Name: ~ + Version: ~ + SimpleDownloadLink: ~ + UIImageSourceLink: ~ + DiscordLink: ~ + ModDBLink: ~ + NewsLink: ~ + DependenceName: ~ + S3HostLink: ~ + S3BucketName: ~ + S3FolderName: ~ + S3HostPublicKey: ~ + S3HostSecretKey: ~ + NetworkInfo: ~ + SupportLink: ~ + """); + + var version = RemoteLauncherCatalogMapper.ToLauncherContentVersion(document); + + version.Name.Should().BeEmpty(); + version.Version.Should().BeEmpty(); + version.SimpleDownloadLink.Should().BeEmpty(); + version.UIImageSourceLink.Should().BeEmpty(); + version.DiscordLink.Should().BeEmpty(); + version.ModDBLink.Should().BeEmpty(); + version.NewsLink.Should().BeEmpty(); + version.ParentContentName.Should().BeEmpty(); + version.S3HostLink.Should().BeEmpty(); + version.S3BucketName.Should().BeEmpty(); + version.S3FolderName.Should().BeEmpty(); + version.S3HostPublicKey.Should().BeEmpty(); + version.S3HostSecretKey.Should().BeEmpty(); + version.NetworkInfo.Should().BeEmpty(); + version.SupportLink.Should().BeEmpty(); + } + + /// + /// An explicitly null palette slot means the same as an undeclared one: the launcher fills it from the active + /// game's palette instead of carrying a null colour into presentation. + /// + [Fact] + public async Task PublishedContentTheme_MapsAnExplicitlyNullSlotToEmptyTextAsync() + { + LegacyContentManifest document = await ReadRemoteYamlAsync( + """ + Name: Contra + ColorsInformation: + GenLauncherActiveColor: '#baff0c' + GenLauncherBorderColor: ~ + """); + + LauncherContentTheme? theme = RemoteLauncherCatalogMapper.ToLauncherContentVersion(document).Theme; + + theme.Should().NotBeNull(); + theme!.GenLauncherBorderColor.Should().BeEmpty(); + theme.GenLauncherActiveColor.Should().Be("#baff0c"); + } + + /// + /// A catalog reference may publish null for its name or its link. The name decides whether the reference is + /// matched against installed content and the link is handed to , so neither may arrive null. + /// + [Fact] + public async Task PublishedCatalogYaml_MapsAnExplicitlyNullModificationReferenceToEmptyTextAsync() + { + LegacyLauncherCatalogDocument document = await ReadRemoteYamlAsync( + """ + modDatas: + - ModName: ~ + ModLink: ~ + """); + + RemoteCatalogModificationReference reference = RemoteLauncherCatalogMapper.ToRemoteCatalog(document) + .Modifications.Should().ContainSingle().Subject; + + reference.Name.Should().BeEmpty(); + reference.ManifestUrl.Should().BeEmpty(); + } + + /// + /// Modifications publish whichever slots they care about, so a partial block has to survive mapping intact. + /// + [Fact] + public async Task PublishedContentYaml_KeepsEveryThemeSlotItDeclaresAsync() + { + LegacyContentManifest document = await ReadRemoteYamlAsync( + """ + Name: Contra + ColorsInformation: + GenLauncherBorderColor: '#00e3ff' + GenLauncherInactiveBorder: DarkGray + GenLauncherInactiveBorder2: '#7a7db0' + GenLauncherActiveColor: '#baff0c' + GenLauncherDarkFillColor: '#232977' + GenLauncherDarkBackGround: '#090502' + GenLauncherLightBackGround: '#B3000000' + GenLauncherDefaultTextColor: White + GenLauncherDownloadTextColor: '#090502' + GenLauncherListBoxSelectionColor1: '#E61d2057' + GenLauncherListBoxSelectionColor2: '#F21d2057' + GenLauncherButtonSelectionColor: '#2534ff' + GenLauncherBackgroundImageLink: https://cdn.example.test/contra.png + """); + + var version = RemoteLauncherCatalogMapper.ToLauncherContentVersion(document); + + version.Theme.Should().BeEquivalentTo(new LauncherContentTheme + { + GenLauncherBorderColor = "#00e3ff", + GenLauncherInactiveBorder = "DarkGray", + GenLauncherInactiveBorder2 = "#7a7db0", + GenLauncherActiveColor = "#baff0c", + GenLauncherDarkFillColor = "#232977", + GenLauncherDarkBackGround = "#090502", + GenLauncherLightBackGround = "#B3000000", + GenLauncherDefaultTextColor = "White", + GenLauncherDownloadTextColor = "#090502", + GenLauncherListBoxSelectionColor1 = "#E61d2057", + GenLauncherListBoxSelectionColor2 = "#F21d2057", + GenLauncherButtonSelectionColor = "#2534ff", + GenLauncherBackgroundImageLink = "https://cdn.example.test/contra.png" + }); + } + + /// + /// A child manifest is reached through the parent that lists it, and that link is authoritative: a manifest + /// shared between parents declares only one of them, so the caller's parent has to win. + /// + [Fact] + public async Task PublishedContentYaml_PrefersCallerSuppliedParentOverDeclaredDependenceAsync() + { + LegacyContentManifest document = await ReadRemoteYamlAsync( + """ + ModificationType: Addon + Name: HD Textures + Version: '1.0' + DependenceName: ShockWave + """); + + var version = RemoteLauncherCatalogMapper.ToLauncherContentVersion(document, "Contra"); + + version.ParentContentName.Should().Be("Contra"); + } + + /// + /// An empty block carries no intent, so it must not be mistaken for a modification asking to be re-skinned. + /// + [Fact] + public async Task PublishedContentYamlWithoutUsableThemeValues_MapsToNoThemeAsync() + { + LegacyContentManifest document = await ReadRemoteYamlAsync( + """ + Name: Contra + ColorsInformation: + GenLauncherActiveColor: ' ' + """); + + RemoteLauncherCatalogMapper.ToLauncherContentVersion(document).Theme.Should().BeNull(); + } + + [Theory] + [InlineData(nameof(LegacyContentThemeManifest.GenLauncherBorderColor))] + [InlineData(nameof(LegacyContentThemeManifest.GenLauncherInactiveBorder))] + [InlineData(nameof(LegacyContentThemeManifest.GenLauncherInactiveBorder2))] + [InlineData(nameof(LegacyContentThemeManifest.GenLauncherActiveColor))] + [InlineData(nameof(LegacyContentThemeManifest.GenLauncherDarkFillColor))] + [InlineData(nameof(LegacyContentThemeManifest.GenLauncherDarkBackGround))] + [InlineData(nameof(LegacyContentThemeManifest.GenLauncherLightBackGround))] + [InlineData(nameof(LegacyContentThemeManifest.GenLauncherDefaultTextColor))] + [InlineData(nameof(LegacyContentThemeManifest.GenLauncherDownloadTextColor))] + [InlineData(nameof(LegacyContentThemeManifest.GenLauncherListBoxSelectionColor1))] + [InlineData(nameof(LegacyContentThemeManifest.GenLauncherListBoxSelectionColor2))] + [InlineData(nameof(LegacyContentThemeManifest.GenLauncherButtonSelectionColor))] + [InlineData(nameof(LegacyContentThemeManifest.GenLauncherBackgroundImageLink))] + public void PublishedContentTheme_KeepsAnySingleDeclaredSlot(string propertyName) + { + LegacyContentThemeManifest theme = CreateThemeWithSingleValue(propertyName); + var document = new LegacyContentManifest { ColorsInformation = theme }; + + var result = RemoteLauncherCatalogMapper.ToLauncherContentVersion(document); + + result.Theme.Should().NotBeNull(); + } + + [Fact] + public void ToRemoteCatalog_NullCollections_MapsEmptyCollections() + { + var document = new LegacyLauncherCatalogDocument + { + AdvData = null!, + modDatas = null!, + originalGameAddons = null!, + originalGamePatches = null! + }; + + RemoteLauncherCatalog result = RemoteLauncherCatalogMapper.ToRemoteCatalog(document); + + result.AdvertisingEntries.Should().BeEmpty(); + result.Modifications.Should().BeEmpty(); + result.OriginalGameAddonManifestUrls.Should().BeEmpty(); + result.OriginalGamePatchManifestUrls.Should().BeEmpty(); + } + + [Fact] + public async Task PublishedContentYaml_UsesDeclaredSourceKindWhenPackageMetadataIsAbsentAsync() + { + LegacyContentManifest document = await ReadRemoteYamlAsync( + """ + Name: Manually Installed + ContentSourceKind: Manual + """); + + var version = RemoteLauncherCatalogMapper.ToLauncherContentVersion(document); + + version.ModificationType.Should().Be(ModificationType.Mod); + version.Name.Should().Be("Manually Installed"); + version.Version.Should().BeEmpty(); + version.Deprecated.Should().BeFalse(); + version.Installation.ContentSourceKind.Should().Be(ContentSourceKind.Manual); + } + + [Fact] + public void ToRemoteCatalog_ReturnsEmptyCatalogForNullManifest() + { + RemoteLauncherCatalog result = RemoteLauncherCatalogMapper.ToRemoteCatalog(null); + + result.Should().BeSameAs(RemoteLauncherCatalog.Empty); + } + + [Theory] + [InlineData(null, "")] + [InlineData("ShockWave", "ShockWave")] + public void ToLauncherContentVersion_NullManifest_MapsParentWithEmptyDefaults( + string? parentContentName, + string expectedParentContentName) + { + var result = RemoteLauncherCatalogMapper.ToLauncherContentVersion(null, parentContentName); + + result.Should().BeEquivalentTo(new LauncherContentVersion + { + ParentContentName = expectedParentContentName + }); + } + + private static async Task ReadRemoteYamlAsync(string yaml) + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(yaml, Encoding.UTF8) + }); + using HttpClient httpClient = new(handler); + HttpRemoteYamlDocumentReader reader = new(httpClient); + + return await reader.ReadYamlAsync( + new Uri("https://example.test/catalog.yaml"), + CancellationToken.None); + } + + private static LegacyContentThemeManifest CreateThemeWithSingleValue(string propertyName) + { + var theme = new LegacyContentThemeManifest(); + typeof(LegacyContentThemeManifest).GetProperty(propertyName)!.SetValue(theme, "published"); + return theme; + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Persistence/Services/AtomicFileWriterTests.cs b/GenLauncherGO.Tests/Infrastructure/Persistence/Services/AtomicFileWriterTests.cs new file mode 100644 index 00000000..430e1bac --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Persistence/Services/AtomicFileWriterTests.cs @@ -0,0 +1,412 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Persistence.Services; + +namespace GenLauncherGO.Tests.Infrastructure.Persistence.Services; + +public sealed class AtomicFileWriterTests +{ + [Fact] + public void WriteText_MissingDestination_CreatesParentAndUtf8FileWithoutBom() + { + using TestDirectory directory = new(); + string documentPath = directory.GetPath("State/settings.yaml"); + string destinationDirectory = Path.GetDirectoryName(documentPath)!; + const string Contents = "Name: Δ"; + var writer = new AtomicFileWriter(); + + writer.WriteText(documentPath, Contents); + + File.ReadAllBytes(documentPath).Should().Equal(new UTF8Encoding(false).GetBytes(Contents)); + Directory.EnumerateFileSystemEntries(destinationDirectory).Should().ContainSingle() + .Which.Should().Be(documentPath); + } + + [Fact] + public void WriteText_ExistingDestination_ReplacesCompleteContents() + { + using TestDirectory directory = new(); + string documentPath = directory.CreateFile("state.yaml", "Name: original and longer"); + var writer = new AtomicFileWriter(); + + writer.WriteText(documentPath, "Name: replacement"); + + File.ReadAllText(documentPath).Should().Be("Name: replacement"); + Directory.EnumerateFileSystemEntries(directory.Path).Should().ContainSingle() + .Which.Should().Be(documentPath); + } + + [Fact] + public void WriteText_CommitFailure_PreservesOriginalAndCleansTemporaryFile() + { + using TestDirectory directory = new(); + string documentPath = directory.CreateFile("state.yaml", "Name: original"); + var writer = new AtomicFileWriter(); + using FileStream lockedDocument = new( + documentPath, + FileMode.Open, + FileAccess.Read, + FileShare.Read); + + Action act = () => writer.WriteText(documentPath, "Name: replacement"); + + act.Should().Throw(); + File.ReadAllText(documentPath).Should().Be("Name: original"); + Directory.EnumerateFileSystemEntries(directory.Path).Should().ContainSingle() + .Which.Should().Be(documentPath); + } + + /// + /// The destination directory is created when it is missing, so the path chain has to be cleared before that + /// happens: creating it first would plant a launcher directory inside whatever the link resolves to, and the + /// later refusal would not take it back. + /// + [Fact] + public void WriteText_MissingDirectoryUnderLinkedParent_RejectsWithoutCreatingIt() + { + using TestDirectory directory = new(); + string linkPath = directory.GetPath("Linked"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + string documentPath = Path.Combine(linkPath, "State", "state.yaml"); + var writer = new AtomicFileWriter(); + + Action act = () => writer.WriteText(documentPath, "Name: unsafe"); + + act.Should().Throw(); + Directory.EnumerateFileSystemEntries(junction.TargetDirectory).Should().ContainSingle() + .Which.Should().Be(junction.CanaryFilePath); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void WriteText_LinkedParent_RejectsWithoutTouchingTarget() + { + using TestDirectory directory = new(); + string linkPath = directory.GetPath("Linked"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + string documentPath = Path.Combine(linkPath, "state.yaml"); + var writer = new AtomicFileWriter(); + + Action act = () => writer.WriteText(documentPath, "Name: unsafe"); + + act.Should().Throw(); + File.Exists(Path.Combine(junction.TargetDirectory, "state.yaml")).Should().BeFalse(); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public async Task WriteAsync_MissingDestination_WritesCompleteContentsAsync() + { + using TestDirectory directory = new(); + string documentPath = directory.GetPath("State/state.bin"); + string destinationDirectory = Path.GetDirectoryName(documentPath)!; + byte[] expectedBytes = [0, 1, 2, 3, 255]; + var writer = new AtomicFileWriter(); + + await writer.WriteAsync( + documentPath, + (stream, cancellationToken) => stream.WriteAsync(expectedBytes, cancellationToken).AsTask(), + CancellationToken.None); + + File.ReadAllBytes(documentPath).Should().Equal(expectedBytes); + Directory.EnumerateFileSystemEntries(destinationDirectory).Should().ContainSingle() + .Which.Should().Be(documentPath); + } + + /// + /// The staged bytes must land beside the destination and stay invisible to a reader until the commit, which is + /// what makes the replace atomic on the same volume. + /// + [Fact] + public async Task WriteAsync_BeforeCommit_StagesOneSiblingEntryBesideTheUnchangedDestinationAsync() + { + using TestDirectory directory = new(); + string documentPath = directory.CreateFile("state.yaml", "Name: original"); + var writer = new AtomicFileWriter(); + List stagedEntries = []; + string observedContents = string.Empty; + + await writer.WriteAsync( + documentPath, + async (stream, cancellationToken) => + { + stagedEntries.AddRange(Directory.EnumerateFileSystemEntries(directory.Path)); + observedContents = await File.ReadAllTextAsync(documentPath, cancellationToken); + await stream.WriteAsync(Encoding.UTF8.GetBytes("Name: replacement"), cancellationToken); + }, + CancellationToken.None); + + stagedEntries.Should().HaveCount(2).And.Contain(documentPath); + observedContents.Should().Be("Name: original"); + File.ReadAllText(documentPath).Should().Be("Name: replacement"); + Directory.EnumerateFileSystemEntries(directory.Path).Should().ContainSingle() + .Which.Should().Be(documentPath); + } + + [Fact] + public async Task WriteAsync_PreCanceledToken_LeavesDestinationUntouchedAsync() + { + using TestDirectory directory = new(); + string documentPath = directory.CreateFile("state.yaml", "Name: original"); + var writer = new AtomicFileWriter(); + bool writerCalled = false; + using var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + Func act = () => writer.WriteAsync( + documentPath, + (_, _) => + { + writerCalled = true; + return Task.CompletedTask; + }, + cancellationTokenSource.Token); + + await act.Should().ThrowAsync(); + writerCalled.Should().BeFalse(); + File.ReadAllText(documentPath).Should().Be("Name: original"); + Directory.EnumerateFileSystemEntries(directory.Path).Should().ContainSingle() + .Which.Should().Be(documentPath); + } + + [Fact] + public async Task WriteAsync_CanceledDuringWrite_PreservesOriginalAndCleansTemporaryFileAsync() + { + using TestDirectory directory = new(); + string documentPath = directory.CreateFile("state.yaml", "Name: original"); + var writer = new AtomicFileWriter(); + using var cancellationTokenSource = new CancellationTokenSource(); + + Func act = () => writer.WriteAsync( + documentPath, + async (stream, cancellationToken) => + { + byte[] replacement = Encoding.UTF8.GetBytes("Name: replacement"); + await stream.WriteAsync(replacement.AsMemory(), cancellationToken); + await cancellationTokenSource.CancelAsync(); + }, + cancellationTokenSource.Token); + + await act.Should().ThrowAsync(); + File.ReadAllText(documentPath).Should().Be("Name: original"); + Directory.EnumerateFileSystemEntries(directory.Path).Should().ContainSingle() + .Which.Should().Be(documentPath); + } + + [Fact] + public async Task WriteAsync_WriterFailure_PreservesOriginalAndCleansTemporaryFileAsync() + { + using TestDirectory directory = new(); + string documentPath = directory.CreateFile("state.yaml", "Name: original"); + var writer = new AtomicFileWriter(); + + Func act = () => writer.WriteAsync( + documentPath, + async (stream, cancellationToken) => + { + byte[] replacement = Encoding.UTF8.GetBytes("Name: replacement"); + await stream.WriteAsync(replacement.AsMemory(), cancellationToken); + throw new IOException("simulated write failure"); + }, + CancellationToken.None); + + await act.Should().ThrowAsync(); + File.ReadAllText(documentPath).Should().Be("Name: original"); + Directory.EnumerateFileSystemEntries(directory.Path).Should().ContainSingle() + .Which.Should().Be(documentPath); + } + + [Fact] + public async Task WriteAsync_LinkedDestination_RejectsWithoutTouchingTargetAsync() + { + using TestDirectory directory = new(); + string documentPath = directory.GetPath("state.yaml"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, documentPath); + var writer = new AtomicFileWriter(); + bool writerCalled = false; + + Func act = () => writer.WriteAsync( + documentPath, + (_, _) => + { + writerCalled = true; + return Task.CompletedTask; + }, + CancellationToken.None); + + await act.Should().ThrowAsync(); + writerCalled.Should().BeFalse(); + Directory.EnumerateFileSystemEntries(junction.TargetDirectory).Should().ContainSingle() + .Which.Should().Be(junction.CanaryFilePath); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + /// + /// A link planted between the safety check and the commit must still be refused, because the commit is the step + /// that would follow it and overwrite whatever the link resolves to. + /// + [Fact] + public async Task WriteAsync_DestinationLinkedDuringWrite_RejectsCommitWithoutTouchingTargetAsync() + { + using TestDirectory directory = new(); + string documentPath = directory.GetPath("state.yaml"); + var writer = new AtomicFileWriter(); + ProtectedJunction? junction = null; + + Func act = () => writer.WriteAsync( + documentPath, + async (stream, cancellationToken) => + { + await stream.WriteAsync(Encoding.UTF8.GetBytes("Name: replacement"), cancellationToken); + junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, documentPath); + }, + CancellationToken.None); + + await act.Should().ThrowAsync(); + junction.Should().NotBeNull(); + Directory.EnumerateFileSystemEntries(junction!.TargetDirectory).Should().ContainSingle() + .Which.Should().Be(junction.CanaryFilePath); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + /// + /// Another writer can create the destination between the safety check and the commit, and the commit still owns + /// the final contents. + /// + [Fact] + public async Task WriteAsync_DestinationCreatedDuringWrite_ReplacesItWithTheCommittedContentsAsync() + { + using TestDirectory directory = new(); + string documentPath = directory.GetPath("State/state.yaml"); + string destinationDirectory = Path.GetDirectoryName(documentPath)!; + var writer = new AtomicFileWriter(); + + await writer.WriteAsync( + documentPath, + async (stream, cancellationToken) => + { + await stream.WriteAsync(Encoding.UTF8.GetBytes("committed document"), cancellationToken); + await File.WriteAllTextAsync(documentPath, "racing document", cancellationToken); + }, + CancellationToken.None); + + File.ReadAllText(documentPath).Should().Be("committed document"); + Directory.EnumerateFileSystemEntries(destinationDirectory).Should().ContainSingle() + .Which.Should().Be(documentPath); + } + + [Fact] + public async Task WriteAsync_ConcurrentCreatorsLeaveOneCompleteDocumentAndNoTemporaryFilesAsync() + { + using TestDirectory directory = new(); + string documentPath = directory.GetPath("State/state.yaml"); + string destinationDirectory = Path.GetDirectoryName(documentPath)!; + var writer = new AtomicFileWriter(); + TaskCompletionSource firstReady = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource secondReady = new(TaskCreationOptions.RunContinuationsAsynchronously); + TaskCompletionSource releaseWriters = new(TaskCreationOptions.RunContinuationsAsynchronously); + + Task firstWrite = writer.WriteAsync( + documentPath, + async (stream, cancellationToken) => + { + await stream.WriteAsync(Encoding.UTF8.GetBytes("first complete document"), cancellationToken); + firstReady.TrySetResult(); + await releaseWriters.Task.WaitAsync(cancellationToken); + }, + CancellationToken.None); + Task secondWrite = writer.WriteAsync( + documentPath, + async (stream, cancellationToken) => + { + await stream.WriteAsync(Encoding.UTF8.GetBytes("second complete document"), cancellationToken); + secondReady.TrySetResult(); + await releaseWriters.Task.WaitAsync(cancellationToken); + }, + CancellationToken.None); + + try + { + await Task.WhenAll(firstReady.Task, secondReady.Task).WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + } + finally + { + releaseWriters.TrySetResult(); + } + + await Task.WhenAll(firstWrite, secondWrite).WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + File.ReadAllText(documentPath).Should().BeOneOf( + "first complete document", + "second complete document"); + Directory.EnumerateFileSystemEntries(destinationDirectory).Should().ContainSingle() + .Which.Should().Be(documentPath); + } + + [Fact] + public async Task WriteFileIfMissingAsync_MissingDestination_PublishesCompletedFileAsync() + { + using TestDirectory directory = new(); + string destinationPath = directory.GetPath("Images/asset.png"); + string destinationDirectory = Path.GetDirectoryName(destinationPath)!; + var writer = new AtomicFileWriter(); + + bool committed = await writer.WriteFileIfMissingAsync( + destinationPath, + (temporaryPath, cancellationToken) => + File.WriteAllTextAsync(temporaryPath, "complete asset", cancellationToken), + CancellationToken.None); + + committed.Should().BeTrue(); + File.ReadAllText(destinationPath).Should().Be("complete asset"); + Directory.EnumerateFileSystemEntries(destinationDirectory).Should().ContainSingle() + .Which.Should().Be(destinationPath); + } + + [Fact] + public async Task WriteFileIfMissingAsync_ExistingDestination_SkipsWriterAndKeepsContentsAsync() + { + using TestDirectory directory = new(); + string destinationPath = directory.CreateFile("asset.png", "existing asset"); + var writer = new AtomicFileWriter(); + bool writerCalled = false; + + bool committed = await writer.WriteFileIfMissingAsync( + destinationPath, + (_, _) => + { + writerCalled = true; + return Task.CompletedTask; + }, + CancellationToken.None); + + committed.Should().BeFalse(); + writerCalled.Should().BeFalse(); + File.ReadAllText(destinationPath).Should().Be("existing asset"); + } + + [Fact] + public async Task WriteFileIfMissingAsync_DestinationCreatedDuringWrite_KeepsConcurrentFileAsync() + { + using TestDirectory directory = new(); + string destinationPath = directory.GetPath("asset.png"); + var writer = new AtomicFileWriter(); + + bool committed = await writer.WriteFileIfMissingAsync( + destinationPath, + async (temporaryPath, cancellationToken) => + { + await File.WriteAllTextAsync(temporaryPath, "staged asset", cancellationToken); + await File.WriteAllTextAsync(destinationPath, "concurrent asset", cancellationToken); + }, + CancellationToken.None); + + committed.Should().BeFalse(); + File.ReadAllText(destinationPath).Should().Be("concurrent asset"); + Directory.EnumerateFileSystemEntries(directory.Path).Should().ContainSingle() + .Which.Should().Be(destinationPath); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Persistence/Services/YamlDocumentStoreTests.cs b/GenLauncherGO.Tests/Infrastructure/Persistence/Services/YamlDocumentStoreTests.cs new file mode 100644 index 00000000..0f9cc147 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Persistence/Services/YamlDocumentStoreTests.cs @@ -0,0 +1,107 @@ +using System; +using System.IO; +using GenLauncherGO.Infrastructure.Persistence.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Persistence.Services; + +public sealed class YamlDocumentStoreTests +{ + [Fact] + public void Load_WhenDocumentIsMissing_ReturnsDefaultDocument() + { + using var directory = new TestDirectory(); + var defaultDocument = new TestDocument { Name = "default" }; + IYamlDocumentStore store = CreateStore(Path.Combine(directory.Path, "state.yaml")); + + TestDocument document = store.Load(defaultDocument); + + document.Should().BeSameAs(defaultDocument); + } + + [Fact] + public void Load_WhenDocumentIsMalformed_ReturnsDefaultDocument() + { + 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); + + TestDocument document = store.Load(defaultDocument); + + document.Should().BeSameAs(defaultDocument); + } + + /// + /// Launcher state decides what gets deployed into a user's game folder. A document reached through a link is + /// state somebody else placed there, so it is discarded in favour of the caller's default rather than trusted. + /// + [Fact] + public void Load_WhenDocumentPathCrossesALink_ReturnsDefaultDocument() + { + using var directory = new TestDirectory(); + string linkPath = directory.GetPath("Linked"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget(directory, linkPath); + File.WriteAllText(Path.Combine(junction.TargetDirectory, "state.yaml"), "Name: planted"); + var defaultDocument = new TestDocument { Name = "default" }; + IYamlDocumentStore store = CreateStore(Path.Combine(linkPath, "state.yaml")); + + TestDocument document = store.Load(defaultDocument); + + document.Should().BeSameAs(defaultDocument); + } + + [Fact] + public void Save_WritesDocumentThatCanBeLoaded() + { + 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 + }; + + store.Save(document); + TestDocument loadedDocument = store.Load(new TestDocument()); + + loadedDocument.Name.Should().Be("ShockWave"); + loadedDocument.Version.Should().Be("1.2"); + loadedDocument.Installed.Should().BeTrue(); + File.Exists(documentPath).Should().BeTrue(); + } + + [Fact] + public void Save_WhenDocumentPathIsDirectory_PropagatesPersistenceFailure() + { + using var directory = new TestDirectory(); + string documentPath = Path.Combine(directory.Path, "State"); + Directory.CreateDirectory(documentPath); + IYamlDocumentStore store = CreateStore(documentPath); + + Action act = () => store.Save(new TestDocument { Name = "ShockWave" }); + + act.Should().Throw(); + Directory.Exists(documentPath).Should().BeTrue(); + } + + private static YamlDocumentStore CreateStore(string documentPath) + { + return new YamlDocumentStore( + documentPath, + new AtomicFileWriter(), + 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..982e779b --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteAssetDownloaderTests.cs @@ -0,0 +1,173 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Persistence.Services; +using GenLauncherGO.Infrastructure.Remote; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Remote; + +public sealed class HttpRemoteAssetDownloaderTests +{ + [Fact] + public async Task DownloadIfMissingAsync_DeletesStaleTemporaryFileAndDoesNotResumeAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "asset.png"); + string temporaryFilePath = destinationFilePath + ".download"; + await File.WriteAllTextAsync(temporaryFilePath, "stale", TestContext.Current.CancellationToken); + RecordingFileDownloader fileDownloader = new() + { + Handler = (request, cancellationToken) => + File.WriteAllTextAsync(request.DestinationFilePath, "fresh", cancellationToken) + }; + HttpRemoteAssetDownloader downloader = CreateDownloader(fileDownloader); + + await downloader.DownloadIfMissingAsync( + new Uri("https://example.test/asset.png"), + destinationFilePath, + CancellationToken.None); + + fileDownloader.Requests.Should().ContainSingle() + .Which.Resume.Should().BeFalse(); + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("fresh"); + File.Exists(temporaryFilePath).Should().BeFalse(); + } + + /// + /// The transfer never resumes, so leftover bytes from an interrupted session have to be gone before the next + /// one starts writing into the same staging file. + /// + [Fact] + public async Task DownloadIfMissingAsync_StaleTemporaryFile_IsGoneWhenTheDownloadStartsAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "asset.png"); + await File.WriteAllTextAsync(destinationFilePath + ".download", "stale", TestContext.Current.CancellationToken); + bool? staleFileExistedAtDownload = null; + RecordingFileDownloader fileDownloader = new() + { + Handler = (request, cancellationToken) => + { + staleFileExistedAtDownload = File.Exists(request.DestinationFilePath); + return File.WriteAllTextAsync(request.DestinationFilePath, "fresh", cancellationToken); + } + }; + HttpRemoteAssetDownloader downloader = CreateDownloader(fileDownloader); + + await downloader.DownloadIfMissingAsync( + new Uri("https://example.test/asset.png"), + destinationFilePath, + CancellationToken.None); + + staleFileExistedAtDownload.Should().BeFalse(); + } + + /// + /// Remote assets are cached below folders the launcher creates on demand, so the destination folder cannot be + /// assumed to exist when the download is requested. + /// + [Fact] + public async Task DownloadIfMissingAsync_MissingDestinationDirectory_IsCreatedBeforeDownloadingAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "Images", "Contra", "asset.png"); + RecordingFileDownloader fileDownloader = new() + { + Handler = (request, cancellationToken) => + File.WriteAllTextAsync(request.DestinationFilePath, "fresh", cancellationToken) + }; + HttpRemoteAssetDownloader downloader = CreateDownloader(fileDownloader); + + await downloader.DownloadIfMissingAsync( + new Uri("https://example.test/asset.png"), + destinationFilePath, + CancellationToken.None); + + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("fresh"); + } + + /// + /// Closing the launcher cancels asset downloads mid-transfer. The partially staged file must never be published + /// as the finished asset, because a present destination is what stops the next session downloading it again. + /// + [Fact] + public async Task DownloadIfMissingAsync_CanceledDuringDownload_LeavesTheDestinationMissingAsync() + { + using TestDirectory testDirectory = new(); + using CancellationTokenSource cancellation = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "asset.png"); + RecordingFileDownloader fileDownloader = new() + { + Handler = async (request, cancellationToken) => + { + await File.WriteAllTextAsync(request.DestinationFilePath, "partial", cancellationToken); + await cancellation.CancelAsync(); + } + }; + HttpRemoteAssetDownloader downloader = CreateDownloader(fileDownloader); + + Func act = () => downloader.DownloadIfMissingAsync( + new Uri("https://example.test/asset.png"), + destinationFilePath, + cancellation.Token); + + await act.Should().ThrowAsync(); + File.Exists(destinationFilePath).Should().BeFalse(); + } + + [Fact] + public async Task DownloadIfMissingAsync_ExistingDestination_KeepsContentsWithoutDownloadingAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = testDirectory.CreateFile("asset.png", "already downloaded"); + RecordingFileDownloader fileDownloader = new(); + HttpRemoteAssetDownloader downloader = CreateDownloader(fileDownloader); + + await downloader.DownloadIfMissingAsync( + new Uri("https://example.test/asset.png"), + destinationFilePath, + CancellationToken.None); + + fileDownloader.Requests.Should().BeEmpty(); + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("already downloaded"); + } + + /// + /// Another session can finish the same asset while this download runs, so the completed destination must win + /// over the temporary file this call staged. + /// + [Fact] + public async Task DownloadIfMissingAsync_DestinationAppearsDuringDownload_KeepsItAndRemovesTemporaryFileAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "asset.png"); + string temporaryFilePath = destinationFilePath + ".download"; + RecordingFileDownloader fileDownloader = new() + { + Handler = async (request, cancellationToken) => + { + await File.WriteAllTextAsync(request.DestinationFilePath, "this download", cancellationToken); + await File.WriteAllTextAsync(destinationFilePath, "the other session", cancellationToken); + } + }; + HttpRemoteAssetDownloader downloader = CreateDownloader(fileDownloader); + + await downloader.DownloadIfMissingAsync( + new Uri("https://example.test/asset.png"), + destinationFilePath, + CancellationToken.None); + + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("the other session"); + File.Exists(temporaryFilePath).Should().BeFalse(); + } + + private static HttpRemoteAssetDownloader CreateDownloader(RecordingFileDownloader fileDownloader) + { + return new HttpRemoteAssetDownloader( + fileDownloader, + new AtomicFileWriter(), + NullLogger.Instance); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteConnectionProbeTests.cs b/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteConnectionProbeTests.cs new file mode 100644 index 00000000..4bb1d13c --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteConnectionProbeTests.cs @@ -0,0 +1,115 @@ +using System; +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() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.NoContent)); + HttpRemoteConnectionProbe probe = CreateProbe(handler); + + bool canConnect = await probe.CanConnectAsync( + new Uri("https://example.test/catalog.yml"), + CancellationToken.None); + + canConnect.Should().BeTrue(); + handler.Methods.Should().Equal(HttpMethod.Head); + } + + [Theory] + [InlineData(HttpStatusCode.MethodNotAllowed)] + [InlineData(HttpStatusCode.NotImplemented)] + public async Task CanConnectAsync_FallsBackToGetWhenHeadIsNotAllowedAsync(HttpStatusCode headStatusCode) + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(headStatusCode)); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.OK)); + HttpRemoteConnectionProbe probe = CreateProbe(handler); + + bool canConnect = await probe.CanConnectAsync( + new Uri("https://example.test/catalog.yml"), + CancellationToken.None); + + canConnect.Should().BeTrue(); + handler.Methods.Should().Equal(HttpMethod.Head, HttpMethod.Get); + } + + [Fact] + public async Task CanConnectAsync_FallsBackToGetWhenHeadReturnsAnotherFailureAsync() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.OK)); + HttpRemoteConnectionProbe probe = CreateProbe(handler); + + bool canConnect = await probe.CanConnectAsync( + new Uri("https://example.test/catalog.yml"), + CancellationToken.None); + + canConnect.Should().BeTrue(); + handler.Methods.Should().Equal(HttpMethod.Head, HttpMethod.Get); + } + + [Fact] + public async Task CanConnectAsync_ReturnsFalseWhenRequestsFailAsync() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => throw new HttpRequestException("network down")); + HttpRemoteConnectionProbe probe = CreateProbe(handler); + + bool canConnect = await probe.CanConnectAsync( + new Uri("https://example.test/catalog.yml"), + CancellationToken.None); + + canConnect.Should().BeFalse(); + } + + [Fact] + public async Task CanConnectAsync_ReturnsFalseWhenProbeTimesOutAsync() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => throw new TaskCanceledException("timeout")); + HttpRemoteConnectionProbe probe = CreateProbe(handler); + + bool canConnect = await probe.CanConnectAsync( + new Uri("https://example.test/catalog.yml"), + CancellationToken.None); + + canConnect.Should().BeFalse(); + } + + /// + /// A caller-requested cancellation must reach the caller instead of being reported as an unreachable endpoint. + /// + [Fact] + public async Task CanConnectAsync_CanceledToken_PropagatesCancellationAsync() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => throw new TaskCanceledException("canceled")); + HttpRemoteConnectionProbe probe = CreateProbe(handler); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + Func act = () => probe.CanConnectAsync( + new Uri("https://example.test/catalog.yml"), + cancellation.Token); + + await act.Should().ThrowAsync(); + } + + private static HttpRemoteConnectionProbe CreateProbe(QueueHttpMessageHandler handler) + { + return new HttpRemoteConnectionProbe( + NullLogger.Instance, + new HttpClient(handler)); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteYamlDocumentReaderTests.cs b/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteYamlDocumentReaderTests.cs new file mode 100644 index 00000000..988d2e22 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Remote/HttpRemoteYamlDocumentReaderTests.cs @@ -0,0 +1,93 @@ +using System; +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 +{ + /// + /// The backend adds keys the launcher has never seen without a schema change, so the fixture carries a scalar + /// and a nested mapping the document does not declare. + /// + [Fact] + public async Task ReadYamlAsync_DeserializesRemoteYamlAsync() + { + const string RemoteYaml = """ + Name: ShockWave + Version: '1.2' + FutureField: whatever + FutureSection: + Nested: value + Deeper: + Key: 1 + + """; + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(RemoteYaml, Encoding.UTF8) + }); + HttpRemoteYamlDocumentReader reader = new(new HttpClient(handler)); + + RemoteDocument document = await reader.ReadYamlAsync( + new Uri("https://example.test/catalog.yml"), + CancellationToken.None); + + 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() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.InternalServerError)); + HttpRemoteYamlDocumentReader reader = new(new HttpClient(handler)); + + Func act = () => reader.ReadYamlAsync( + new Uri("https://example.test/catalog.yml"), + CancellationToken.None); + + await act.Should().ThrowAsync(); + } + + /// + /// A caller-requested cancellation must reach the caller unchanged instead of being reported as a failed read. + /// + [Fact] + public async Task ReadYamlAsync_CanceledToken_PropagatesCancellationAsync() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => throw new TaskCanceledException("canceled")); + HttpRemoteYamlDocumentReader reader = new(new HttpClient(handler)); + using CancellationTokenSource cancellation = new(); + cancellation.Cancel(); + + Func act = () => reader.ReadYamlAsync( + new Uri("https://example.test/catalog.yml"), + cancellation.Token); + + await act.Should().ThrowAsync(); + } + + /// + /// A stand-in for a remote YAML document. + /// + /// + /// The setters exist for the deserializer, not for this file, so they look unused to a "make it read-only" + /// inspection. Removing them makes deserialization silently yield empty values instead of failing to build. + /// + private sealed class RemoteDocument + { + public string Name { get; set; } = string.Empty; + + public string Version { get; set; } = string.Empty; + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Remote/SharedHttpClientFactoryTests.cs b/GenLauncherGO.Tests/Infrastructure/Remote/SharedHttpClientFactoryTests.cs new file mode 100644 index 00000000..4060e458 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Remote/SharedHttpClientFactoryTests.cs @@ -0,0 +1,136 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Net; +using System.Net.Http; +using System.Net.Sockets; +using System.Text; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Remote; + +namespace GenLauncherGO.Tests.Infrastructure.Remote; + +/// +/// Pins the transport contract shared by every launcher HTTP client. +/// +/// +/// Automatic decompression has to stay off. The resumable downloader resumes at a byte offset and measures its +/// progress against the length the server declared, so a handler that silently inflated the body would write +/// more bytes than that length and leave every resumed transfer corrupt. The server here is a loopback socket +/// so the invariant is observed the way a real server would expose it, without reaching the network. +/// +public sealed class SharedHttpClientFactoryTests +{ + [Fact] + public async Task Create_LeavesContentEncodingUnnegotiatedAndUnappliedAsync() + { + byte[] compressedBody = Compress("payload"); + (int Port, Task Request) server = StartLoopbackServer(compressedBody); + byte[] received; + + using (HttpClient httpClient = SharedHttpClientFactory.Create(TestTimeouts.Wait)) + { + received = await httpClient.GetByteArrayAsync(new Uri($"http://127.0.0.1:{server.Port}/asset"), TestContext.Current.CancellationToken); + } + + string request = await server.Request; + received.Should().Equal( + compressedBody, + "a decompressing handler would hand back the inflated body instead of the bytes on the wire"); + request.Should().NotContain( + "Accept-Encoding", + "advertising an encoding invites the compressed response that breaks resume arithmetic"); + request.Should().Contain("User-Agent: GenLauncherGO/1"); + } + + [Fact] + public void Create_AppliesTheRequestedTimeout() + { + using HttpClient httpClient = SharedHttpClientFactory.Create(TimeSpan.FromSeconds(37)); + + httpClient.Timeout.Should().Be(TimeSpan.FromSeconds(37)); + } + + /// + /// Starts a loopback server that serves one gzip-encoded response. The returned task owns the listener and + /// closes it, so the caller never holds a socket it might dispose while the exchange is still running. + /// + private static (int Port, Task Request) StartLoopbackServer(byte[] body) + { + TcpListener listener = new(IPAddress.Loopback, 0); + listener.Start(); + return (((IPEndPoint)listener.LocalEndpoint).Port, RespondOnceAsync(listener, body)); + } + + /// + /// Serves one gzip-encoded response and returns the raw request text the client sent. + /// + private static async Task RespondOnceAsync(TcpListener listener, byte[] body) + { + try + { + return await ExchangeAsync(listener, body); + } + finally + { + listener.Stop(); + } + } + + private static async Task ExchangeAsync(TcpListener listener, byte[] body) + { + using TcpClient client = await listener.AcceptTcpClientAsync(); + await using NetworkStream stream = client.GetStream(); + string request = await ReadRequestHeadersAsync(stream); + string responseHeaders = + "HTTP/1.1 200 OK\r\n" + + "Content-Encoding: gzip\r\n" + + "Content-Type: application/octet-stream\r\n" + + $"Content-Length: {body.Length}\r\n" + + "Connection: close\r\n\r\n"; + + await stream.WriteAsync(Encoding.ASCII.GetBytes(responseHeaders)); + await stream.WriteAsync(body); + await stream.FlushAsync(); + return request; + } + + private static async Task ReadRequestHeadersAsync(NetworkStream stream) + { + StringBuilder text = new(); + byte[] buffer = new byte[1]; + while (!EndsWithBlankLine(text)) + { + int read = await stream.ReadAsync(buffer); + if (read == 0) + { + break; + } + + text.Append((char)buffer[0]); + } + + return text.ToString(); + } + + private static bool EndsWithBlankLine(StringBuilder text) + { + return text.Length >= 4 && + text[^4] == '\r' && + text[^3] == '\n' && + text[^2] == '\r' && + text[^1] == '\n'; + } + + private static byte[] Compress(string payload) + { + using MemoryStream output = new(); + using (GZipStream gzip = new(output, CompressionLevel.Optimal, true)) + { + byte[] bytes = Encoding.UTF8.GetBytes(payload); + gzip.Write(bytes, 0, bytes.Length); + } + + return output.ToArray(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Settings/Services/PreferencesServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Settings/Services/PreferencesServiceTests.cs new file mode 100644 index 00000000..0be3f229 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Settings/Services/PreferencesServiceTests.cs @@ -0,0 +1,632 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Settings.Exceptions; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Persistence.Services; +using GenLauncherGO.Infrastructure.Settings.Models; +using GenLauncherGO.Infrastructure.Settings.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Settings.Services; + +public sealed class PreferencesServiceTests +{ + [Fact] + public void Current_WhenPreferencesFileIsMissing_ReturnsCurrentSchemaDefaults() + { + using var directory = new TestDirectory(); + + PreferencesService service = CreateService( + Path.Combine(directory.Path, "LauncherPreferences.yaml")); + + service.Current.Should().Be(new LauncherPreferences()); + } + + [Fact] + public void Current_WhenPreferencesFileIsMalformed_ReturnsDefaults() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + "Installations: ["); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Should().Be(new LauncherPreferences()); + string resetYaml = File.ReadAllText(preferencesFilePath); + resetYaml.Should().Contain("SchemaVersion: 1"); + resetYaml.Should().NotContain("Installations: ["); + } + + [Fact] + public void Current_MigratesUnversionedFlatPreferencesToCurrentSchema() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + """ + LaunchesCount: 7 + AutoDeleteOldVersions: true + SelectedGameClient: generalszh.exe + """); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Shared.AutoDeleteOldVersions.Should().BeTrue(); + service.Current.Games.ZeroHour.LaunchesCount.Should().Be(7); + service.Current.Games.ZeroHour.SelectedGameClient.Should().Be("generalszh.exe"); + + string migratedYaml = File.ReadAllText(preferencesFilePath); + migratedYaml.Should().Contain("SchemaVersion: 1"); + migratedYaml.Should().Contain("Shared:"); + migratedYaml.Should().Contain("Games:"); + File.ReadAllLines(preferencesFilePath).Should().NotContain("LaunchesCount: 7"); + File.ReadAllLines(preferencesFilePath).Should().NotContain("AutoDeleteOldVersions: true"); + } + + [Fact] + public void Current_MigratesSchemaZeroFlatPreferencesToCurrentSchema() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + """ + SchemaVersion: 0 + LaunchesCount: 4 + AutoDeleteOldVersions: true + SelectedGameClient: generalszh.exe + """); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Shared.AutoDeleteOldVersions.Should().BeTrue(); + service.Current.Games.ZeroHour.LaunchesCount.Should().Be(4); + service.Current.Games.ZeroHour.SelectedGameClient.Should().Be("generalszh.exe"); + string migratedYaml = File.ReadAllText(preferencesFilePath); + migratedYaml.Should().Contain("SchemaVersion: 1"); + migratedYaml.Should().NotContain("SchemaVersion: 0"); + File.ReadAllLines(preferencesFilePath).Should().NotContain("LaunchesCount: 4"); + } + + [Fact] + public void Current_WhenSchemaIsNewerThanSupported_ResetsToCurrentSchemaDefaults() + { + using var directory = new TestDirectory(); + const string FuturePreferences = + """ + SchemaVersion: 2 + Shared: + AutoDeleteOldVersions: true + FutureSetting: keep-me + """; + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + FuturePreferences); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Should().Be(new LauncherPreferences()); + string resetYaml = File.ReadAllText(preferencesFilePath); + resetYaml.Should().Contain("SchemaVersion: 1"); + resetYaml.Should().NotContain("FutureSetting"); + } + + [Fact] + public void Current_WhenFutureSchemaHasIncompatibleCurrentFieldShape_ResetsAndRewritesDefaults() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + """ + SchemaVersion: 2 + Shared: + - incompatible-future-shape + """); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Should().Be(new LauncherPreferences()); + string resetYaml = File.ReadAllText(preferencesFilePath); + resetYaml.Should().Contain("SchemaVersion: 1"); + resetYaml.Should().NotContain("SchemaVersion: 2"); + resetYaml.Should().NotContain("incompatible-future-shape"); + } + + [Fact] + public void Current_WhenCurrentSchemaHasIncompatibleFieldShape_ResetsAndRewritesDefaults() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + """ + SchemaVersion: 1 + Shared: + - incompatible-current-shape + """); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Should().Be(new LauncherPreferences()); + string resetYaml = File.ReadAllText(preferencesFilePath); + resetYaml.Should().Contain("SchemaVersion: 1"); + resetYaml.Should().NotContain("incompatible-current-shape"); + } + + [Fact] + public void Current_WhenUnversionedSchemaIsUnknown_ResetsToCurrentSchemaDefaults() + { + using var directory = new TestDirectory(); + const string UnknownPreferences = "FutureSetting: keep-me"; + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + UnknownPreferences); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Should().Be(new LauncherPreferences()); + string resetYaml = File.ReadAllText(preferencesFilePath); + resetYaml.Should().Contain("SchemaVersion: 1"); + resetYaml.Should().NotContain("FutureSetting"); + } + + [Fact] + public void Current_NormalizesNullableCurrentSchemaMembers() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + """ + SchemaVersion: 1 + Installations: + Generals: + ZeroHour: + LastSelectedGame: Unknown + Shared: + AutoDeleteOldVersions: true + Games: + Generals: + LaunchesCount: -2 + SelectedGameClient: + ZeroHour: + GameArguments: + """); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Installations.Should().Be(new LauncherInstallations()); + service.Current.LastSelectedGame.Should().BeNull(); + service.Current.Shared.AutoDeleteOldVersions.Should().BeTrue(); + service.Current.Games.Generals.LaunchesCount.Should().Be(0); + service.Current.Games.Generals.SelectedGameClient.Should().BeEmpty(); + service.Current.Games.Generals.SelectedWorldBuilder.Should().BeEmpty(); + service.Current.Games.Generals.WorldBuilderArguments.Should().BeEmpty(); + service.Current.Games.ZeroHour.GameArguments.Should().BeEmpty(); + } + + /// + /// Only the game a user has actually configured reaches the file, so a document that carries one game section + /// must still load the other game at its defaults instead of failing the whole read. + /// + [Fact] + public void Current_WhenOnlyOneGameSectionIsPersisted_LoadsTheOtherGameAsDefaults() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + """ + SchemaVersion: 1 + Games: + ZeroHour: + LaunchesCount: 5 + SelectedGameClient: generalszh.exe + """); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Games.ZeroHour.LaunchesCount.Should().Be(5); + service.Current.Games.Generals.Should().Be(new LauncherGamePreferences()); + } + + /// + /// A hand-edited or partially written preferences file can hold custom entries without both names. They are + /// dropped at the settings boundary so the launcher still starts with the entries that remain usable. + /// + [Fact] + public void Current_DiscardsPersistedCustomExecutablesMissingAName() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + """ + SchemaVersion: 1 + Games: + ZeroHour: + CustomGameClients: + - + - ExecutableName: nameless.exe + - DisplayName: No File + - DisplayName: Usable + ExecutableName: usable.exe + """); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Games.ZeroHour.CustomGameClients.Should().ContainSingle() + .Which.Should().Be(new LauncherCustomExecutable("Usable", "usable.exe")); + } + + /// + /// GeneralsOnline is a built-in Zero Hour client, so a persisted custom entry that reuses its file name is a + /// duplicate there while being a genuine custom client for Generals. + /// + [Fact] + public void Current_TreatsGeneralsOnlineExecutableAsBuiltInOnlyForZeroHour() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + """ + SchemaVersion: 1 + Games: + ZeroHour: + CustomGameClients: + - DisplayName: Online + ExecutableName: generalsonlinezh.exe + Generals: + CustomGameClients: + - DisplayName: Online + ExecutableName: generalsonlinezh.exe + """); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Games.ZeroHour.CustomGameClients.Should().BeEmpty(); + service.Current.Games.Generals.CustomGameClients.Should().ContainSingle() + .Which.ExecutableName.Should().Be(LauncherFileSystemLayout.GeneralsOnlineExecutableFileName); + } + + /// + /// Custom World Builder entries are offered alongside the built-in ones, so an entry that reuses a built-in + /// file name would otherwise appear twice in the picker. + /// + [Fact] + public void Current_DiscardsCustomWorldBuildersThatReuseABuiltInFileName() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + """ + SchemaVersion: 1 + Games: + ZeroHour: + CustomWorldBuilders: + - DisplayName: Community + ExecutableName: worldbuilderzh.exe + - DisplayName: Retail + ExecutableName: WorldBuilder.exe + - DisplayName: Custom + ExecutableName: custom-editor.exe + """); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Games.ZeroHour.CustomWorldBuilders.Should().ContainSingle() + .Which.ExecutableName.Should().Be("custom-editor.exe"); + } + + /// + /// A document whose schema marker cannot be read is unreadable, not legacy. Migrating its flat keys would + /// import values from a file the launcher never managed to understand. + /// + [Fact] + public void Current_WhenSchemaVersionCannotBeRead_ResetsInsteadOfMigratingFlatValues() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + """ + SchemaVersion: not-a-version + LaunchesCount: 9 + AutoDeleteOldVersions: true + """); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Should().Be(new LauncherPreferences()); + } + + /// + /// The unversioned format only wrote the keys a user had changed, so absent keys have to migrate to the current + /// defaults rather than to unset values. + /// + [Fact] + public void Current_MigratesUnversionedPreferencesThatOmitOptionalKeys() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + "LaunchesCount: 3"); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Games.ZeroHour.LaunchesCount.Should().Be(3); + service.Current.Games.ZeroHour.SelectedGameClient.Should().BeEmpty(); + service.Current.Shared.AutoDeleteOldVersions.Should().BeFalse(); + } + + /// + /// A preferences file reached through a reparse point is never rewritten, so the reset the launcher would + /// otherwise persist has to fail instead of writing through the link. + /// + [Fact] + public void Current_WhenPreferencesAreReachedThroughAReparsePoint_FailsWithoutRewritingTheFile() + { + using var directory = new TestDirectory(); + const string MalformedPreferences = "Installations: ["; + string linkedDirectory = directory.GetPath("LinkedSettings"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + linkedDirectory, + "RealSettings"); + string preferencesFilePath = Path.Combine(linkedDirectory, "LauncherPreferences.yaml"); + File.WriteAllText(preferencesFilePath, MalformedPreferences); + + Action act = () => CreateService(preferencesFilePath); + + act.Should().Throw(); + File.ReadAllText(preferencesFilePath).Should().Be(MalformedPreferences); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + [Fact] + public void Update_PersistsAndReloadsStandaloneSchema() + { + using var directory = new TestDirectory(); + string preferencesFilePath = Path.Combine(directory.Path, "LauncherPreferences.yaml"); + string generalsDirectory = directory.CreateDirectory("Generals"); + string zeroHourDirectory = directory.CreateDirectory("ZeroHour"); + PreferencesService service = CreateService(preferencesFilePath); + var preferences = new LauncherPreferences + { + Installations = new LauncherInstallations + { + Generals = generalsDirectory + Path.DirectorySeparatorChar, + ZeroHour = zeroHourDirectory + }, + LastSelectedGame = SupportedGame.ZeroHour, + Shared = new LauncherSharedPreferences + { + AutoDeleteOldVersions = true, + HideLauncherAfterGameStart = true, + EnableDiagnosticLogging = true, + UseEnglishLanguage = true, + HasShownRetailGenPatcherRecommendation = true + }, + Games = new LauncherGamePreferencesSet + { + Generals = new LauncherGamePreferences + { + LaunchesCount = 3, + SelectedGameClient = " generalsv.exe ", + CustomGameClients = new[] + { + new LauncherCustomExecutable("Generals Client A", "generals-custom-a.exe"), + new LauncherCustomExecutable("Generals Client B", "generals-custom-b.exe") + } + }, + ZeroHour = new LauncherGamePreferences + { + LaunchesCount = 7, + SelectedGameClient = "generalszh.exe", + SelectedWorldBuilder = "worldbuilderzh.exe", + GameArguments = "-quickstart", + WorldBuilderArguments = "-wb", + ModsListVerticalOffset = 123.5, + AdvertisingPositionInList = 2, + CustomGameClients = new[] + { + new LauncherCustomExecutable("Zero Hour Client", "zh-custom.exe") + }, + CustomWorldBuilders = new[] + { + new LauncherCustomExecutable("Map Editor", "map-editor.exe") + } + } + } + }; + + service.Update(preferences); + PreferencesService reloadedService = CreateService(preferencesFilePath); + + LauncherPreferences persisted = reloadedService.Current; + persisted.Installations.Generals.Should().Be(Path.GetFullPath(generalsDirectory)); + persisted.Installations.ZeroHour.Should().Be(Path.GetFullPath(zeroHourDirectory)); + persisted.LastSelectedGame.Should().Be(SupportedGame.ZeroHour); + persisted.Shared.Should().Be(preferences.Shared); + persisted.Games.Generals.SelectedGameClient.Should().Be("generalsv.exe"); + persisted.Games.Generals.CustomGameClients.Should().Equal( + preferences.Games.Generals.CustomGameClients); + persisted.Games.ZeroHour.Should().BeEquivalentTo(preferences.Games.ZeroHour); + persisted.Games.ZeroHour.CustomGameClients.Should().ContainSingle() + .Which.ExecutableName.Should().Be("zh-custom.exe"); + persisted.Games.ZeroHour.CustomWorldBuilders.Should().ContainSingle() + .Which.ExecutableName.Should().Be("map-editor.exe"); + + string yaml = File.ReadAllText(preferencesFilePath); + yaml.Should().Contain("SchemaVersion: 1"); + yaml.Should().Contain("Installations:"); + yaml.Should().Contain("LastSelectedGame: ZeroHour"); + yaml.Should().Contain("Shared:"); + yaml.Should().Contain("EnableDiagnosticLogging: true"); + yaml.Should().Contain("HasShownRetailGenPatcherRecommendation: true"); + yaml.Should().Contain("Games:"); + yaml.Should().Contain("ModsListVerticalOffset: 123.5"); + yaml.Should().Contain("CustomGameClients:"); + yaml.Should().Contain("CustomWorldBuilders:"); + } + + [Fact] + public void Current_NormalizesCustomExecutablesPerGameAndRejectsInvalidOrDuplicateEntries() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateFile( + "LauncherPreferences.yaml", + """ + SchemaVersion: 1 + Games: + ZeroHour: + CustomGameClients: + - DisplayName: First + ExecutableName: custom-one.exe + - DisplayName: first + ExecutableName: custom-two.exe + - DisplayName: Second + ExecutableName: CUSTOM-ONE.EXE + - DisplayName: Built in + ExecutableName: generalszh.exe + - DisplayName: Retail + ExecutableName: generals.exe + - DisplayName: Nested + ExecutableName: tools/custom.exe + CustomWorldBuilders: + - DisplayName: Editor + ExecutableName: editor.exe + Generals: + CustomGameClients: + - DisplayName: Generals Custom + ExecutableName: custom-one.exe + - DisplayName: Generals Retail + ExecutableName: generals.exe + """); + + PreferencesService service = CreateService(preferencesFilePath); + + service.Current.Shared.HasShownRetailGenPatcherRecommendation.Should().BeFalse(); + service.Current.Games.ZeroHour.CustomGameClients.Should().ContainSingle() + .Which.Should().Be(new LauncherCustomExecutable("First", "custom-one.exe")); + service.Current.Games.ZeroHour.CustomWorldBuilders.Should().ContainSingle() + .Which.Should().Be(new LauncherCustomExecutable("Editor", "editor.exe")); + service.Current.Games.Generals.CustomGameClients.Should().ContainSingle() + .Which.Should().Be(new LauncherCustomExecutable("Generals Custom", "custom-one.exe")); + } + + /// + /// The stored scroll offset is reapplied to the mods list on the next start, so a non-finite value has to + /// collapse to the top of the list rather than being kept and restored. + /// + [Theory] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public void Update_NonFiniteModsListVerticalOffset_ResetsTheOffsetToZero(double offset) + { + using var directory = new TestDirectory(); + string preferencesFilePath = Path.Combine(directory.Path, "LauncherPreferences.yaml"); + PreferencesService service = CreateService(preferencesFilePath); + + service.Update(new LauncherPreferences + { + Games = new LauncherGamePreferencesSet + { + ZeroHour = new LauncherGamePreferences { ModsListVerticalOffset = offset } + } + }); + + service.Current.Games.ZeroHour.ModsListVerticalOffset.Should().Be(0); + } + + /// + /// An installation path that the Windows path APIs reject is dropped rather than propagated, so the launcher + /// can still start and ask for a usable directory. + /// + [Fact] + public void Update_UnusableInstallationPath_IsDiscarded() + { + using var directory = new TestDirectory(); + string preferencesFilePath = Path.Combine(directory.Path, "LauncherPreferences.yaml"); + PreferencesService service = CreateService(preferencesFilePath); + + service.Update(new LauncherPreferences + { + Installations = new LauncherInstallations { Generals = "C:\\Games\0Generals" } + }); + + service.Current.Installations.Generals.Should().BeNull(); + } + + [Fact] + public void Update_WhenPreferencesAreUnchanged_DoesNotPersistOrRaisePreferencesChanged() + { + using var directory = new TestDirectory(); + string preferencesFilePath = Path.Combine(directory.Path, "LauncherPreferences.yaml"); + PreferencesService service = CreateService(preferencesFilePath); + int changedCount = 0; + service.PreferencesChanged += (_, _) => changedCount++; + + service.Update(new LauncherPreferences()); + + changedCount.Should().Be(0); + File.Exists(preferencesFilePath).Should().BeFalse(); + } + + [Fact] + public void Update_WhenPreferencesChange_RaisesPreferencesChangedWithNormalizedState() + { + using var directory = new TestDirectory(); + string preferencesFilePath = Path.Combine(directory.Path, "LauncherPreferences.yaml"); + PreferencesService service = CreateService(preferencesFilePath); + LauncherPreferences? changedPreferences = null; + service.PreferencesChanged += (_, current) => changedPreferences = current; + var preferences = new LauncherPreferences + { + Games = new LauncherGamePreferencesSet + { + ZeroHour = new LauncherGamePreferences { GameArguments = "-quickstart" } + } + }; + + service.Update(preferences); + + changedPreferences.Should().Be(service.Current); + changedPreferences!.Games.ZeroHour.GameArguments.Should().Be("-quickstart"); + } + + [Fact] + public void Update_WhenPreferencesCannotBePersisted_KeepsCurrentAndDoesNotPublish() + { + using var directory = new TestDirectory(); + string preferencesFilePath = directory.CreateDirectory("LauncherPreferences.yaml"); + PreferencesService service = CreateService(preferencesFilePath); + LauncherPreferences? changedPreferences = null; + service.PreferencesChanged += (_, current) => changedPreferences = current; + var preferences = new LauncherPreferences + { + Shared = new LauncherSharedPreferences { AutoDeleteOldVersions = true } + }; + + Action act = () => service.Update(preferences); + + act.Should().Throw() + .WithInnerException(); + service.Current.Should().Be(new LauncherPreferences()); + changedPreferences.Should().BeNull(); + Directory.Exists(preferencesFilePath).Should().BeTrue(); + } + + private static PreferencesService CreateService(string preferencesFilePath) + { + return new PreferencesService( + new YamlDocumentStore( + preferencesFilePath, + new AtomicFileWriter(), + NullLogger>.Instance), + new YamlDocumentStore( + preferencesFilePath, + new AtomicFileWriter(), + NullLogger>.Instance), + new YamlDocumentStore( + preferencesFilePath, + new AtomicFileWriter(), + NullLogger>.Instance)); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Shell/Services/WindowsLauncherShellServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Shell/Services/WindowsLauncherShellServiceTests.cs new file mode 100644 index 00000000..68d8a40c --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Shell/Services/WindowsLauncherShellServiceTests.cs @@ -0,0 +1,176 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using GenLauncherGO.Infrastructure.Shell.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Shell.Services; + +public sealed class WindowsLauncherShellServiceTests +{ + private readonly List _openedTargets = []; + + [Fact] + public void OpenUri_DoesNotLaunchEmptyUri() + { + WindowsLauncherShellService service = CreateService(); + + service.OpenUri(" "); + + _openedTargets.Should().BeEmpty(); + } + + [Fact] + public void OpenUri_DoesNotLaunchRelativeUri() + { + WindowsLauncherShellService service = CreateService(); + + service.OpenUri("not-a-uri"); + + _openedTargets.Should().BeEmpty(); + } + + [Fact] + public void OpenUri_DoesNotLaunchUnsupportedScheme() + { + WindowsLauncherShellService service = CreateService(); + + service.OpenUri("ftp://example.test/file.big"); + + _openedTargets.Should().BeEmpty(); + } + + [Fact] + public void OpenUri_OpensNormalizedHttpTarget() + { + WindowsLauncherShellService service = CreateService(); + + service.OpenUri("HTTPS://Example.Test/mods?id=1"); + + _openedTargets.Should().Equal("https://example.test/mods?id=1"); + } + + /// + /// A shell that refuses the target must not take the launcher down with it. The refusal is swallowed, so the + /// report to the caller-supplied logger is the only trace left for diagnosing "the link does nothing". + /// + [Fact] + public void OpenUri_WhenTheShellRefusesTheTarget_ReportsTheFailureWithoutThrowing() + { + RecordingLogger logger = new(); + WindowsLauncherShellService service = new(logger, _ => throw new Win32Exception(5)); + + Action act = () => service.OpenUri("https://example.test/mods"); + + act.Should().NotThrow(); + logger.Entries.Should().Contain(entry => + entry.LogLevel == LogLevel.Warning && + entry.Exception is Win32Exception); + } + + [Fact] + public void OpenFolder_DoesNotLaunchEmptyFolder() + { + WindowsLauncherShellService service = CreateService(); + + service.OpenFolder(" "); + + _openedTargets.Should().BeEmpty(); + } + + [Fact] + public void OpenFolder_DoesNotLaunchInvalidPath() + { + WindowsLauncherShellService service = CreateService(); + + service.OpenFolder("bad\0path"); + + _openedTargets.Should().BeEmpty(); + } + + [Fact] + public void OpenFolder_DoesNotLaunchMissingFolder() + { + using TestDirectory directory = new(); + WindowsLauncherShellService service = CreateService(); + string missingFolder = Path.Combine(directory.Path, "missing"); + + service.OpenFolder(missingFolder); + + _openedTargets.Should().BeEmpty(); + } + + [Fact] + public void OpenFolder_CreatesMissingFolderWhenRequested() + { + using TestDirectory directory = new(); + string missingFolder = Path.Combine(directory.Path, "Logs"); + WindowsLauncherShellService service = CreateService(); + + service.OpenFolder(missingFolder, createIfMissing: true); + + Directory.Exists(missingFolder).Should().BeTrue(); + _openedTargets.Should().Equal(Path.GetFullPath(missingFolder)); + } + + [Fact] + public void OpenFolder_DoesNotLaunchWhenMissingFolderCannotBeCreated() + { + using TestDirectory directory = new(); + string filePath = Path.Combine(directory.Path, "Logs"); + File.WriteAllText(filePath, "not a directory"); + WindowsLauncherShellService service = CreateService(); + + service.OpenFolder(filePath, createIfMissing: true); + + _openedTargets.Should().BeEmpty(); + } + + [Fact] + public void OpenFolder_DoesNotLaunchEmptyFolderWhenFilesAreRequired() + { + using TestDirectory directory = new(); + WindowsLauncherShellService service = CreateService(); + + service.OpenFolder(directory.Path, true); + + _openedTargets.Should().BeEmpty(); + } + + [Fact] + public void OpenFolder_OpensExistingFolder() + { + using TestDirectory directory = new(); + WindowsLauncherShellService service = CreateService(); + + service.OpenFolder(directory.Path); + + _openedTargets.Should().Equal(Path.GetFullPath(directory.Path)); + } + + [Fact] + public void OpenFolder_OpensExistingFolderWhenRequiredFilesExist() + { + using TestDirectory directory = new(); + File.WriteAllText(Path.Combine(directory.Path, "file.txt"), "content"); + WindowsLauncherShellService service = CreateService(); + + service.OpenFolder(directory.Path, true); + + _openedTargets.Should().Equal(Path.GetFullPath(directory.Path)); + } + + private WindowsLauncherShellService CreateService() + { + return CreateService(_openedTargets.Add); + } + + 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..a7303bdb --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Startup/FileSystemLauncherPathResolverTests.cs @@ -0,0 +1,120 @@ +using System.IO; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Startup; + +namespace GenLauncherGO.Tests.Infrastructure.Startup; + +public sealed class FileSystemLauncherPathResolverTests +{ + [Fact] + public void Resolve_UsesExecutableDirectoryWithoutInferringAGame() + { + using var directory = new TestDirectory(); + var resolver = new FileSystemLauncherPathResolver(); + + LauncherStoragePaths paths = resolver.Resolve(directory.Path); + + paths.ExecutableDirectory.Should().Be(Path.GetFullPath(directory.Path)); + } + + [Fact] + public void PrepareLauncherDirectories_CreatesOnlySharedStorage() + { + using var directory = new TestDirectory(); + var resolver = new FileSystemLauncherPathResolver(); + LauncherStoragePaths paths = resolver.Resolve(directory.Path); + string generalsDataDirectory = paths.CreateGamePaths(SupportedGame.Generals, directory.Path) + .OwnedGameDataDirectory; + string zeroHourDataDirectory = paths.CreateGamePaths(SupportedGame.ZeroHour, directory.Path) + .OwnedGameDataDirectory; + + resolver.PrepareLauncherDirectories(paths); + + Directory.Exists(paths.DataDirectory).Should().BeTrue(); + Directory.Exists(paths.LogsDirectory).Should().BeTrue(); + Directory.Exists(generalsDataDirectory).Should().BeFalse(); + Directory.Exists(zeroHourDataDirectory).Should().BeFalse(); + } + + [Fact] + public void PrepareGameDirectories_CreatesIsolatedLayoutAndClearsOnlyTemp() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = directory.CreateDirectory("Game"); + var resolver = new FileSystemLauncherPathResolver(); + LauncherStoragePaths storage = resolver.Resolve(executableDirectory); + resolver.PrepareLauncherDirectories(storage); + LauncherPaths paths = storage.CreateGamePaths(SupportedGame.ZeroHour, gameDirectory); + string staleTempFile = Path.Combine(paths.TempDirectory, "download.part"); + string deploymentJournal = Path.Combine(paths.DeploymentDirectory, "journal.json"); + Directory.CreateDirectory(paths.TempDirectory); + Directory.CreateDirectory(paths.DeploymentDirectory); + File.WriteAllText(staleTempFile, string.Empty); + File.WriteAllText(deploymentJournal, string.Empty); + + resolver.PrepareGameDirectories(paths, true); + + 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.TempDirectory).Should().BeTrue(); + Directory.Exists(paths.DeploymentDirectory).Should().BeTrue(); + Directory.Exists(paths.IntegrityDirectory).Should().BeTrue(); + Directory.Exists(paths.StateDirectory).Should().BeTrue(); + Directory.EnumerateFileSystemEntries(paths.TempDirectory).Should().BeEmpty(); + File.Exists(deploymentJournal).Should().BeTrue(); + } + + /// + /// Skipping the temporary cleanup is still a full preparation. The staging and deployment folders are created + /// nowhere else, so a caller that keeps existing temporary content still needs them on disk afterwards. + /// + [Fact] + public void PrepareGameDirectories_WithoutClearingTemporaryContent_StillCreatesTheIsolatedLayout() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = directory.CreateDirectory("Game"); + var resolver = new FileSystemLauncherPathResolver(); + LauncherStoragePaths storage = resolver.Resolve(executableDirectory); + resolver.PrepareLauncherDirectories(storage); + LauncherPaths paths = storage.CreateGamePaths(SupportedGame.Generals, gameDirectory); + + resolver.PrepareGameDirectories(paths, false); + + Directory.Exists(paths.TempDirectory).Should().BeTrue(); + Directory.Exists(paths.DeploymentDirectory).Should().BeTrue(); + Directory.Exists(paths.ModsDirectory).Should().BeTrue(); + Directory.Exists(paths.StateDirectory).Should().BeTrue(); + } + + /// + /// A download the launcher suspended on close leaves its partial content staged under the temporary packages + /// folder. Clearing that on the next startup is what would silently restart the transfer from zero. + /// + [Fact] + public void PrepareGameDirectories_KeepsStagedPackagesWhileClearingOtherTemporaryContent() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = directory.CreateDirectory("Game"); + var resolver = new FileSystemLauncherPathResolver(); + LauncherStoragePaths storage = resolver.Resolve(executableDirectory); + resolver.PrepareLauncherDirectories(storage); + LauncherPaths paths = storage.CreateGamePaths(SupportedGame.ZeroHour, gameDirectory); + string stagedPackageFile = Path.Combine(paths.PackagesDirectory, "Contra", "contra.big"); + string staleTempFile = Path.Combine(paths.TempDirectory, "scratch.tmp"); + Directory.CreateDirectory(Path.GetDirectoryName(stagedPackageFile)!); + Directory.CreateDirectory(paths.TempDirectory); + File.WriteAllText(stagedPackageFile, "partial"); + File.WriteAllText(staleTempFile, string.Empty); + + resolver.PrepareGameDirectories(paths, true); + + File.Exists(stagedPackageFile).Should().BeTrue(); + File.ReadAllText(stagedPackageFile).Should().Be("partial"); + File.Exists(staleTempFile).Should().BeFalse(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Startup/WindowsGameInstallationRegistryTests.cs b/GenLauncherGO.Tests/Infrastructure/Startup/WindowsGameInstallationRegistryTests.cs new file mode 100644 index 00000000..68744015 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Startup/WindowsGameInstallationRegistryTests.cs @@ -0,0 +1,122 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Startup; +using Microsoft.Win32; + +namespace GenLauncherGO.Tests.Infrastructure.Startup; + +public sealed class WindowsGameInstallationRegistryTests +{ + private const string GeneralsKey = + @"SOFTWARE\Electronic Arts\EA Games\Generals"; + + private const string ZeroHourEaKey = + @"SOFTWARE\Electronic Arts\EA Games\Command and Conquer Generals Zero Hour"; + + private const string ZeroHourSteamKey = + @"SOFTWARE\Electronic Arts\EA Games\ZeroHour"; + + private const string FirstDecadeKey = + @"SOFTWARE\Electronic Arts\EA Games\Command and Conquer The First Decade"; + + [Fact] + public void ReadCandidates_QueriesGeneralsSourcesInGeneralsGameCodeOrder() + { + var reads = new List<(RegistryView View, string KeyName, string ValueName)>(); + int candidateNumber = 0; + var registry = new WindowsGameInstallationRegistry((view, keyName, valueName) => + { + reads.Add((view, keyName, valueName)); + candidateNumber++; + return $@"C:\Candidate{candidateNumber}"; + }); + + IReadOnlyList candidates = registry.ReadCandidates(SupportedGame.Generals); + + reads.Should().Equal( + (RegistryView.Registry32, GeneralsKey, "InstallPath"), + (RegistryView.Registry64, GeneralsKey, "InstallPath"), + (RegistryView.Registry32, FirstDecadeKey, "gr_folder"), + (RegistryView.Registry64, FirstDecadeKey, "gr_folder"), + (RegistryView.Registry32, GeneralsKey, "installPath"), + (RegistryView.Registry64, GeneralsKey, "installPath")); + candidates.Should().Equal( + @"C:\Candidate1", + @"C:\Candidate2", + @"C:\Candidate3", + @"C:\Candidate4", + @"C:\Candidate5", + @"C:\Candidate6"); + } + + [Fact] + public void ReadCandidates_QueriesZeroHourSourcesInGeneralsGameCodeOrder() + { + var reads = new List<(RegistryView View, string KeyName, string ValueName)>(); + int candidateNumber = 0; + var registry = new WindowsGameInstallationRegistry((view, keyName, valueName) => + { + reads.Add((view, keyName, valueName)); + candidateNumber++; + return $@"C:\Candidate{candidateNumber}"; + }); + + IReadOnlyList candidates = registry.ReadCandidates(SupportedGame.ZeroHour); + + reads.Should().Equal( + (RegistryView.Registry32, ZeroHourEaKey, "InstallPath"), + (RegistryView.Registry64, ZeroHourEaKey, "InstallPath"), + (RegistryView.Registry32, FirstDecadeKey, "zh_folder"), + (RegistryView.Registry64, FirstDecadeKey, "zh_folder"), + (RegistryView.Registry32, ZeroHourSteamKey, "installPath"), + (RegistryView.Registry64, ZeroHourSteamKey, "installPath")); + candidates.Should().Equal( + @"C:\Candidate1", + @"C:\Candidate2", + @"C:\Candidate3", + @"C:\Candidate4", + @"C:\Candidate5", + @"C:\Candidate6"); + } + + [Fact] + public void ReadCandidates_PrefersZeroHourInstallRootOverSteamDataSubdirectory() + { + const string InstallRoot = @"C:\Steam\Command & Conquer Generals - Zero Hour"; + const string SteamDataDirectory = + @"C:\Steam\Command & Conquer Generals - Zero Hour\ZH_Generals"; + var registry = new WindowsGameInstallationRegistry((_, keyName, _) => keyName switch + { + ZeroHourEaKey => InstallRoot, + ZeroHourSteamKey => SteamDataDirectory, + _ => null + }); + + IReadOnlyList candidates = registry.ReadCandidates(SupportedGame.ZeroHour); + + candidates.Should().Equal(InstallRoot, SteamDataDirectory); + } + + [Fact] + public void ReadCandidates_DeduplicatesEquivalentPathsWithoutChangingPriority() + { + string[] values = + [ + "\"C:\\Steam\\Zero Hour\\\"", + @"C:\Steam\Zero Hour", + @"C:\EA\Zero Hour", + @"c:\ea\zero hour", + @"C:\Retail\Zero Hour", + string.Empty + ]; + int readIndex = 0; + var registry = new WindowsGameInstallationRegistry((_, _, _) => values[readIndex++]); + + IReadOnlyList candidates = registry.ReadCandidates(SupportedGame.ZeroHour); + + candidates.Should().Equal( + @"C:\Steam\Zero Hour", + @"C:\EA\Zero Hour", + @"C:\Retail\Zero Hour"); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Startup/WindowsGameInstallationServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Startup/WindowsGameInstallationServiceTests.cs new file mode 100644 index 00000000..04d00428 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Startup/WindowsGameInstallationServiceTests.cs @@ -0,0 +1,452 @@ +using System; +using System.Collections.Generic; +using System.IO; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Startup.Contracts; +using GenLauncherGO.Core.Startup.Models; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Startup; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Startup; + +public sealed class WindowsGameInstallationServiceTests +{ + [Theory] + [InlineData(SupportedGame.Generals, LauncherFileSystemLayout.GeneralsCommunityExecutableFileName)] + [InlineData(SupportedGame.Generals, LauncherFileSystemLayout.RetailGameExecutableFileName)] + [InlineData(SupportedGame.ZeroHour, LauncherFileSystemLayout.GeneralsOnlineExecutableFileName)] + [InlineData(SupportedGame.ZeroHour, LauncherFileSystemLayout.ZeroHourCommunityExecutableFileName)] + [InlineData(SupportedGame.ZeroHour, LauncherFileSystemLayout.RetailGameExecutableFileName)] + public void Validate_AcceptsRootWithMatchingBuiltInExecutable( + SupportedGame game, + string executableName) + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = CreateDirectoryWithExecutable(directory, "Game", executableName); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(game, gameDirectory, executableDirectory); + + result.IsValid.Should().BeTrue(); + result.Failure.Should().Be(GameInstallationValidationFailure.None); + result.CanonicalPath.Should().Be(PhysicalDirectoryPath.ResolveExisting(gameDirectory)); + } + + [Theory] + [InlineData(SupportedGame.Generals)] + [InlineData(SupportedGame.ZeroHour)] + public void Validate_RejectsRootWithoutBuiltInExecutable(SupportedGame game) + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = directory.CreateDirectory("Game"); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(game, gameDirectory, executableDirectory); + + result.Failure.Should().Be(GameInstallationValidationFailure.BuiltInExecutableNotFound); + } + + [Theory] + [InlineData(SupportedGame.Generals, LauncherFileSystemLayout.ZeroHourCommunityExecutableFileName)] + [InlineData(SupportedGame.ZeroHour, LauncherFileSystemLayout.GeneralsCommunityExecutableFileName)] + public void Validate_RejectsBuiltInExecutableForDifferentGame( + SupportedGame game, + string executableName) + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = CreateDirectoryWithExecutable(directory, "Game", executableName); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(game, gameDirectory, executableDirectory); + + result.Failure.Should().Be(GameInstallationValidationFailure.BuiltInExecutableNotFound); + } + + [SymbolicLinkFact] + public void Validate_RejectsBuiltInExecutableReachedThroughSymbolicLink() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = directory.CreateDirectory("Game"); + string targetPath = directory.CreateFile("target.exe", string.Empty); + SymbolicLinkTestSupport.CreateFileLink( + Path.Combine(gameDirectory, LauncherFileSystemLayout.GeneralsCommunityExecutableFileName), + targetPath); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(SupportedGame.Generals, gameDirectory, executableDirectory); + + result.Failure.Should().Be(GameInstallationValidationFailure.BuiltInExecutableNotFound); + } + + [Fact] + public void ValidateInstallations_TreatsQuotedAndUnquotedDirectoryAsDuplicate() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = CreateDirectoryWithExecutable( + directory, + "Game", + LauncherFileSystemLayout.RetailGameExecutableFileName); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + LauncherInstallationsValidationResult result = service.ValidateInstallations( + new LauncherInstallations + { + Generals = gameDirectory, + ZeroHour = $"\"{gameDirectory}\"" + }, + executableDirectory); + + result.GeneralsValidation.IsValid.Should().BeTrue(); + result.ZeroHourValidation.IsValid.Should().BeTrue(); + result.HasDuplicatePath.Should().BeTrue(); + } + + [Fact] + public void Validate_RejectsExecutableInsideGameInstallation() + { + using var directory = new TestDirectory(); + string gameDirectory = directory.CreateDirectory("Game"); + string executableDirectory = Directory.CreateDirectory( + Path.Combine(gameDirectory, "Launcher")).FullName; + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(SupportedGame.ZeroHour, gameDirectory, executableDirectory); + + result.Failure.Should().Be(GameInstallationValidationFailure.LauncherLocationOverlapsGame); + } + + [Fact] + public void Validate_AcceptsGameInstallationBelowExecutableDirectoryWhenOutsideLauncherData() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = CreateDirectoryWithExecutable( + directory, + Path.Combine("Launcher", "Game"), + LauncherFileSystemLayout.GeneralsCommunityExecutableFileName); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(SupportedGame.Generals, gameDirectory, executableDirectory); + + result.IsValid.Should().BeTrue(); + result.CanonicalPath.Should().Be(PhysicalDirectoryPath.ResolveExisting(gameDirectory)); + } + + [Fact] + public void Validate_RejectsGameInstallationInsideLauncherOwnedData() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = directory.CreateDirectory( + Path.Combine("Launcher", LauncherFileSystemLayout.LauncherDataFolderName, "Game")); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(SupportedGame.Generals, gameDirectory, executableDirectory); + + result.Failure.Should().Be(GameInstallationValidationFailure.UnsafeFileSystemPath); + } + + [Fact] + public void Validate_RejectsInstallationReachedThroughReparsePoint() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string linkedDirectory = directory.GetPath("LinkedGame"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + linkedDirectory, + "RealGame"); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(SupportedGame.Generals, linkedDirectory, executableDirectory); + + result.Failure.Should().Be(GameInstallationValidationFailure.UnsafeFileSystemPath); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + /// + /// The launcher writes its owned data below its own directory, so a launcher location reached through a link is + /// exactly as unsafe as a linked game directory. + /// + [Fact] + public void Validate_RejectsLauncherLocationReachedThroughReparsePoint() + { + using var directory = new TestDirectory(); + string gameDirectory = CreateDirectoryWithExecutable( + directory, + "Game", + LauncherFileSystemLayout.GeneralsCommunityExecutableFileName); + string linkedExecutableDirectory = directory.GetPath("LinkedLauncher"); + ProtectedJunction junction = ReparsePointTestSupport.CreateJunctionToProtectedTarget( + directory, + linkedExecutableDirectory, + "RealLauncher"); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(SupportedGame.Generals, gameDirectory, linkedExecutableDirectory); + + result.Failure.Should().Be(GameInstallationValidationFailure.UnsafeFileSystemPath); + junction.ReadCanary().Should().Be(junction.CanaryContents); + } + + /// + /// Setup shows a message for every validation failure, so a directory value Windows cannot even turn into a + /// path has to arrive as an actionable result instead of an exception. + /// + [Fact] + public void Validate_UnusableDirectoryValue_ReportsPathUnavailable() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(SupportedGame.ZeroHour, "C:\\Games\0Zero Hour", executableDirectory); + + result.Failure.Should().Be(GameInstallationValidationFailure.PathUnavailable); + } + + /// + /// Both supplied paths are inspected for safety, and neither inspection may escape as an exception. + /// + [Fact] + public void Validate_UnusableExecutableDirectoryValue_ReportsPathUnavailable() + { + using var directory = new TestDirectory(); + string gameDirectory = CreateDirectoryWithExecutable( + directory, + "Game", + LauncherFileSystemLayout.ZeroHourCommunityExecutableFileName); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(SupportedGame.ZeroHour, gameDirectory, "C:\\Launcher\0Data"); + + result.Failure.Should().Be(GameInstallationValidationFailure.PathUnavailable); + } + + /// + /// The launcher folder can be moved or deleted while the launcher runs. Canonicalizing it then fails, and + /// setup has to report that rather than propagate the failure. + /// + [Fact] + public void Validate_MissingExecutableDirectory_ReportsPathUnavailable() + { + using var directory = new TestDirectory(); + string gameDirectory = CreateDirectoryWithExecutable( + directory, + "Game", + LauncherFileSystemLayout.ZeroHourCommunityExecutableFileName); + string missingExecutableDirectory = directory.GetPath("RemovedLauncher"); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationValidationResult result = + service.Validate(SupportedGame.ZeroHour, gameDirectory, missingExecutableDirectory); + + result.Failure.Should().Be(GameInstallationValidationFailure.PathUnavailable); + } + + /// + /// A session without a detected game carries . Validating against it has no + /// meaning, so it is rejected instead of silently producing a failure result callers would show to the user. + /// + [Fact] + public void Validate_UnsupportedGame_IsRejected() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string gameDirectory = CreateDirectoryWithExecutable( + directory, + "Game", + LauncherFileSystemLayout.RetailGameExecutableFileName); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + Action act = () => service.Validate(SupportedGame.Unknown, gameDirectory, executableDirectory); + + act.Should().Throw(); + } + + [Fact] + public void DiscoverValidInstallations_NeverOverwritesValidConfiguredPath() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string configuredDirectory = CreateDirectoryWithExecutable( + directory, + "ConfiguredGenerals", + LauncherFileSystemLayout.GeneralsCommunityExecutableFileName); + string registryDirectory = CreateDirectoryWithExecutable( + directory, + "RegistryGenerals", + LauncherFileSystemLayout.GeneralsCommunityExecutableFileName); + var registry = new FakeRegistry(); + registry.Add(SupportedGame.Generals, registryDirectory); + WindowsGameInstallationService service = CreateService(registry); + var current = new LauncherInstallations { Generals = configuredDirectory }; + + LauncherInstallations discovered = + service.DiscoverValidInstallations(current, executableDirectory); + + discovered.Generals.Should().Be(configuredDirectory); + } + + [Fact] + public void DiscoverValidInstallations_SkipsMissingRegistryCandidateAndFillsMissingPath() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string missingDirectory = directory.GetPath("Missing"); + string validDirectory = CreateDirectoryWithExecutable( + directory, + "ZeroHour", + LauncherFileSystemLayout.ZeroHourCommunityExecutableFileName); + var registry = new FakeRegistry(); + registry.Add(SupportedGame.ZeroHour, missingDirectory, validDirectory); + WindowsGameInstallationService service = CreateService(registry); + + LauncherInstallations discovered = + service.DiscoverValidInstallations(new LauncherInstallations(), executableDirectory); + + discovered.ZeroHour.Should().Be(PhysicalDirectoryPath.ResolveExisting(validDirectory)); + discovered.Generals.Should().BeNull(); + } + + [Fact] + public void DiscoverValidInstallations_UsesFirstValidRegistryCandidate() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string steamDirectory = CreateDirectoryWithExecutable( + directory, + "SteamZeroHour", + LauncherFileSystemLayout.RetailGameExecutableFileName); + string eaDirectory = CreateDirectoryWithExecutable( + directory, + "EaZeroHour", + LauncherFileSystemLayout.RetailGameExecutableFileName); + var registry = new FakeRegistry(); + registry.Add(SupportedGame.ZeroHour, steamDirectory, eaDirectory); + WindowsGameInstallationService service = CreateService(registry); + + LauncherInstallations discovered = + service.DiscoverValidInstallations(new LauncherInstallations(), executableDirectory); + + discovered.ZeroHour.Should().Be(PhysicalDirectoryPath.ResolveExisting(steamDirectory)); + } + + /// + /// A retail directory satisfies both games, and discovery walks Zero Hour first. Filling both entries with one + /// directory would leave the launcher deploying Generals content into a Zero Hour installation. + /// + [Fact] + public void DiscoverValidInstallations_CandidateValidForBothGames_FillsOnlyZeroHour() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string sharedDirectory = CreateDirectoryWithExecutable( + directory, + "Retail", + LauncherFileSystemLayout.RetailGameExecutableFileName); + var registry = new FakeRegistry(); + registry.Add(SupportedGame.ZeroHour, sharedDirectory); + registry.Add(SupportedGame.Generals, sharedDirectory); + WindowsGameInstallationService service = CreateService(registry); + + LauncherInstallations discovered = + service.DiscoverValidInstallations(new LauncherInstallations(), executableDirectory); + + discovered.ZeroHour.Should().Be(PhysicalDirectoryPath.ResolveExisting(sharedDirectory)); + discovered.Generals.Should().BeNull(); + } + + [Fact] + public void DiscoverValidInstallations_ConfiguredPathAlsoOfferedToTheOtherGame_KeepsTheOtherGameEmpty() + { + using var directory = new TestDirectory(); + string executableDirectory = directory.CreateDirectory("Launcher"); + string sharedDirectory = CreateDirectoryWithExecutable( + directory, + "Retail", + LauncherFileSystemLayout.RetailGameExecutableFileName); + var registry = new FakeRegistry(); + registry.Add(SupportedGame.Generals, sharedDirectory); + WindowsGameInstallationService service = CreateService(registry); + var current = new LauncherInstallations { ZeroHour = sharedDirectory }; + + LauncherInstallations discovered = + service.DiscoverValidInstallations(current, executableDirectory); + + discovered.ZeroHour.Should().Be(sharedDirectory); + discovered.Generals.Should().BeNull(); + } + + [Fact] + public void FindContainingInstallation_DetectsGameBeforeStandaloneStorageIsCreated() + { + using var directory = new TestDirectory(); + string gameDirectory = CreateDirectoryWithExecutable( + directory, + "ZeroHour", + "generalszh.exe"); + string executableDirectory = directory.CreateDirectory( + Path.Combine("ZeroHour", "Tools", "GenLauncherGO")); + WindowsGameInstallationService service = CreateService(new FakeRegistry()); + + GameInstallationLocation? result = + service.FindContainingInstallation(executableDirectory); + + result.Should().NotBeNull(); + result!.Game.Should().Be(SupportedGame.ZeroHour); + result.Directory.Should().Be(PhysicalDirectoryPath.ResolveExisting(gameDirectory)); + } + + private static WindowsGameInstallationService CreateService(IGameInstallationRegistry registry) + { + return new WindowsGameInstallationService( + registry, + NullLogger.Instance); + } + + private static string CreateDirectoryWithExecutable( + TestDirectory directory, + string relativeDirectory, + string executableName) + { + string gameDirectory = directory.CreateDirectory(relativeDirectory); + File.WriteAllText(Path.Combine(gameDirectory, executableName), string.Empty); + return gameDirectory; + } + + private sealed class FakeRegistry : IGameInstallationRegistry + { + private readonly Dictionary> _candidates = []; + + public IReadOnlyList ReadCandidates(SupportedGame game) + { + return _candidates.TryGetValue(game, out IReadOnlyList? candidates) + ? candidates + : Array.Empty(); + } + + public void Add(SupportedGame game, params string[] candidates) + { + _candidates[game] = candidates; + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Startup/WindowsLauncherHostEnvironmentServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Startup/WindowsLauncherHostEnvironmentServiceTests.cs new file mode 100644 index 00000000..6b07cf3e --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Startup/WindowsLauncherHostEnvironmentServiceTests.cs @@ -0,0 +1,192 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Startup.Contracts; +using GenLauncherGO.Infrastructure.Startup; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Startup; + +public sealed class WindowsLauncherHostEnvironmentServiceTests +{ + /// + /// Every launcher-owned path is resolved against this directory, so it has to be the folder the running + /// executable lives in rather than any other directory the process happens to be able to name. + /// + [Fact] + public void GetExecutableDirectory_ReturnsTheDirectoryHoldingTheRunningExecutable() + { + var service = new WindowsLauncherHostEnvironmentService(); + string runningExecutableDirectory = Path.GetDirectoryName(Environment.ProcessPath!)!; + + string directory = service.GetExecutableDirectory(); + + directory.Should().Be(runningExecutableDirectory); + Directory.Exists(directory).Should().BeTrue(); + } + + [Fact] + public void TryAcquireSingleInstance_ReturnsAcquiredGuardForUnusedName() + { + var service = new WindowsLauncherHostEnvironmentService(); + string instanceName = CreateInstanceName(); + + using ILauncherSingleInstanceGuard guard = service.TryAcquireSingleInstance(instanceName, TimeSpan.Zero); + + guard.IsAcquired.Should().BeTrue(); + } + + [Fact] + public async Task TryAcquireSingleInstance_ReturnsAcquiredGuardWhenNameIsReleasedBeforeRetryAsync() + { + string instanceName = CreateInstanceName(); + using ManualResetEventSlim mutexAcquired = new(); + using ManualResetEventSlim releaseMutex = new(); + using ManualResetEventSlim retryStarted = new(); + using ManualResetEventSlim allowRetry = new(); + var service = new WindowsLauncherHostEnvironmentService( + NullLogger.Instance, + _ => + { + retryStarted.Set(); + allowRetry.Wait(); + }); + Exception? ownerException = null; + Thread ownerThread = new(() => + { + try + { + using Mutex owner = new(true, instanceName, out _); + mutexAcquired.Set(); + releaseMutex.Wait(); + owner.ReleaseMutex(); + } + catch (Exception exception) + { + ownerException = exception; + mutexAcquired.Set(); + } + }) + { + IsBackground = true + }; + + ownerThread.Start(); + try + { + mutexAcquired.Wait(TestTimeouts.Wait, TestContext.Current.CancellationToken).Should().BeTrue(); + + Task acquisition = Task.Run(() => + service.TryAcquireSingleInstance(instanceName, TimeSpan.FromMilliseconds(100))); + retryStarted.Wait(TestTimeouts.Wait, TestContext.Current.CancellationToken).Should().BeTrue(); + + releaseMutex.Set(); + ownerThread.Join(); + allowRetry.Set(); + using ILauncherSingleInstanceGuard guard = + await acquisition.WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + + ownerException.Should().BeNull(); + guard.IsAcquired.Should().BeTrue(); + } + finally + { + releaseMutex.Set(); + allowRetry.Set(); + ownerThread.Join(TestTimeouts.Wait); + } + } + + [Fact] + public void TryAcquireSingleInstance_ReturnsRejectedGuardWhenNameIsAlreadyOwned() + { + var service = new WindowsLauncherHostEnvironmentService(); + string instanceName = CreateInstanceName(); + using ILauncherSingleInstanceGuard firstGuard = service.TryAcquireSingleInstance(instanceName, TimeSpan.Zero); + + using ILauncherSingleInstanceGuard secondGuard = service.TryAcquireSingleInstance(instanceName, TimeSpan.Zero); + + firstGuard.IsAcquired.Should().BeTrue(); + secondGuard.IsAcquired.Should().BeFalse(); + } + + /// + /// A zero retry delay asks for an immediate second attempt. Waiting anyway would stall startup for a launcher + /// that explicitly opted out of the wait. + /// + [Fact] + public void TryAcquireSingleInstance_ZeroRetryDelay_RetriesWithoutWaiting() + { + var requestedWaits = new List(); + var service = new WindowsLauncherHostEnvironmentService( + NullLogger.Instance, + requestedWaits.Add); + string instanceName = CreateInstanceName(); + using ILauncherSingleInstanceGuard firstGuard = service.TryAcquireSingleInstance(instanceName, TimeSpan.Zero); + + using ILauncherSingleInstanceGuard secondGuard = service.TryAcquireSingleInstance(instanceName, TimeSpan.Zero); + + secondGuard.IsAcquired.Should().BeFalse(); + requestedWaits.Should().BeEmpty(); + } + + /// + /// Neither the guard that owns the instance name nor a rejected attempt may keep it reserved after they are + /// released; otherwise closing the running launcher would still block the next one from starting. + /// + [Fact] + public void TryAcquireSingleInstance_AfterHoldingAndRejectedGuardsAreReleased_AllowsANewGuard() + { + var service = new WindowsLauncherHostEnvironmentService(); + string instanceName = CreateInstanceName(); + using (service.TryAcquireSingleInstance(instanceName, TimeSpan.Zero)) + { + service.TryAcquireSingleInstance(instanceName, TimeSpan.Zero).Dispose(); + } + + using ILauncherSingleInstanceGuard replacementGuard = + service.TryAcquireSingleInstance(instanceName, TimeSpan.Zero); + + replacementGuard.IsAcquired.Should().BeTrue(); + } + + [Fact] + public void IsProtectedProgramFilesDirectory_ReturnsFalseForTemporaryDirectory() + { + var service = new WindowsLauncherHostEnvironmentService(); + + bool result = service.IsProtectedProgramFilesDirectory(Path.GetTempPath()); + + result.Should().BeFalse(); + } + + /// + /// Both Program Files roots are protected, so an installation under either one has to be recognized on its own. + /// + [Theory] + [InlineData(Environment.SpecialFolder.ProgramFiles)] + [InlineData(Environment.SpecialFolder.ProgramFilesX86)] + public void IsProtectedProgramFilesDirectory_ProgramFilesRoot_ReturnsTrue(Environment.SpecialFolder programFiles) + { + var service = new WindowsLauncherHostEnvironmentService(); + string programFilesPath = Environment.GetFolderPath(programFiles); + if (string.IsNullOrWhiteSpace(programFilesPath)) + { + return; + } + + bool rootIsProtected = service.IsProtectedProgramFilesDirectory(programFilesPath); + bool installationIsProtected = + service.IsProtectedProgramFilesDirectory(Path.Combine(programFilesPath, "Game")); + + rootIsProtected.Should().BeTrue(); + installationIsProtected.Should().BeTrue(); + } + + 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..9307a03b --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/HttpDownloadFileMetadataReaderTests.cs @@ -0,0 +1,153 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Updating.Clients; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Clients; + +public sealed class HttpDownloadFileMetadataReaderTests +{ + [Fact] + public async Task ReadMetadataAsync_UsesHeadContentDispositionFileNameStarAsync() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse( + HttpStatusCode.OK, + "attachment; filename*=UTF-8''Folder%20Package.big", + 123)); + HttpDownloadFileMetadataReader reader = CreateReader(handler); + Uri uri = new("https://example.test/packages/package.big"); + + DownloadFileMetadata metadata = await reader.ReadMetadataAsync(uri, CancellationToken.None); + + metadata.DownloadUri.Should().Be(uri); + metadata.FileName.Should().Be("Folder Package.big"); + metadata.TotalBytes.Should().Be(123); + handler.Methods.Should().Equal(HttpMethod.Head); + } + + /// + /// The star form carries the encoded name a server sends alongside an ASCII fallback, so it wins whenever both + /// are present. + /// + [Fact] + public async Task ReadMetadataAsync_ContentDispositionCarriesBothFileNameForms_PrefersFileNameStarAsync() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse( + HttpStatusCode.OK, + "attachment; filename=\"ascii.zip\"; filename*=UTF-8''unicode.zip")); + HttpDownloadFileMetadataReader reader = CreateReader(handler); + + DownloadFileMetadata metadata = await reader.ReadMetadataAsync( + new Uri("https://example.test/package"), + CancellationToken.None); + + metadata.FileName.Should().Be("unicode.zip"); + } + + [Theory] + [InlineData(HttpStatusCode.MethodNotAllowed)] + [InlineData(HttpStatusCode.NotImplemented)] + public async Task ReadMetadataAsync_FallsBackToGetWhenHeadIsNotAllowedAsync(HttpStatusCode headStatusCode) + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(headStatusCode)); + handler.Enqueue(_ => CreateResponse( + HttpStatusCode.OK, + "attachment; filename=\"Package.zip\"")); + HttpDownloadFileMetadataReader reader = CreateReader(handler); + + DownloadFileMetadata metadata = await reader.ReadMetadataAsync( + new Uri("https://example.test/package.zip"), + CancellationToken.None); + + metadata.FileName.Should().Be("Package.zip"); + handler.Methods.Should().Equal(HttpMethod.Head, HttpMethod.Get); + } + + /// + /// Only the two "method unsupported" statuses justify a second request; any other failure is the server's answer + /// and must surface instead of being retried as a GET. + /// + [Fact] + public async Task ReadMetadataAsync_HeadFailsWithServerError_ThrowsWithoutFallbackRequestAsync() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.InternalServerError)); + HttpDownloadFileMetadataReader reader = CreateReader(handler); + + Func act = () => reader.ReadMetadataAsync( + new Uri("https://example.test/package.zip"), + CancellationToken.None); + + await act.Should().ThrowAsync(); + handler.Requests.Should().ContainSingle(); + } + + [Fact] + public async Task ReadMetadataAsync_ThrowsWhenNeitherRequestReturnsFileNameAsync() + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK)); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK)); + HttpDownloadFileMetadataReader reader = CreateReader(handler); + + Func act = () => reader.ReadMetadataAsync( + new Uri("https://example.test/package"), + CancellationToken.None); + + 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); + } + + [Theory] + [InlineData("attachment; filename=\"\\\\\"")] + [InlineData("attachment; filename*=UTF-8''Folder%2FPackage.big")] + [InlineData("attachment; filename=\"CON\"")] + [InlineData("attachment; filename=\"Package.zip.\"")] + public async Task ReadMetadataAsync_ThrowsWhenRemoteFileNameIsUnsafeAsync(string contentDisposition) + { + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse( + HttpStatusCode.OK, + contentDisposition)); + HttpDownloadFileMetadataReader reader = CreateReader(handler); + + Func act = () => reader.ReadMetadataAsync( + new Uri("https://example.test/package"), + CancellationToken.None); + + await act.Should().ThrowAsync() + .WithMessage("Download link is incorrect, please contact modification creator and try again later."); + handler.Methods.Should().Equal(HttpMethod.Head); + } + + 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([]) + }; + if (contentDisposition is not null) + { + response.Content.Headers.ContentDisposition = ContentDispositionHeaderValue.Parse(contentDisposition); + } + + response.Content.Headers.ContentLength = contentLength; + return response; + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Clients/MinioClientFactoryTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/MinioClientFactoryTests.cs new file mode 100644 index 00000000..6f19912d --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/MinioClientFactoryTests.cs @@ -0,0 +1,24 @@ +using Minio; +using Subject = GenLauncherGO.Infrastructure.Updating.Clients.MinioClientFactory; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Clients; + +public sealed class MinioClientFactoryTests +{ + [Theory] + [InlineData("s3.example.test", false, "http://s3.example.test", false)] + [InlineData("http://s3.example.test:9000/path", true, "http://s3.example.test:9000", false)] + [InlineData("https://s3.example.test/path", false, "https://s3.example.test", true)] + [InlineData("s3.example.test:443", false, "https://s3.example.test:443", true)] + public void Create_NormalizesEndpointAndResolvesTransportSecurity( + string endpoint, + bool useSsl, + string expectedEndpoint, + bool expectedSecure) + { + IMinioClient client = Subject.Create(endpoint, "access", "secret", useSsl); + + client.Config.Endpoint.Should().Be(expectedEndpoint); + client.Config.Secure.Should().Be(expectedSecure); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Clients/MinioS3ObjectManifestReaderTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/MinioS3ObjectManifestReaderTests.cs new file mode 100644 index 00000000..d8e6a239 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/MinioS3ObjectManifestReaderTests.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +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 +{ + [Theory] + [InlineData("ShockWave/1.2")] + [InlineData("ShockWave/1.2/")] + public async Task ReadManifestAsync_ReturnsPrefixRelativeEntriesAsync(string prefix) + { + S3ObjectManifestRequest? receivedRequest = null; + using CancellationTokenSource cancellationTokenSource = new(); + S3ObjectManifestRequest request = CreateRequest(prefix); + MinioS3ObjectManifestReader reader = new( + NullLogger.Instance, + (manifestRequest, cancellationToken) => + { + receivedRequest = manifestRequest; + cancellationToken.Should().Be(cancellationTokenSource.Token); + return EnumerateObjectsAsync( + new MinioS3ObjectManifestReader.S3ObjectManifestItem( + "ShockWave/1.2/files/launcher.big", + " \"ABC123\" ", + 42), + new MinioS3ObjectManifestReader.S3ObjectManifestItem( + "outside-prefix.big", + "DEF456", + 7)); + }); + + IReadOnlyList entries = await reader.ReadManifestAsync( + request, + cancellationTokenSource.Token); + + entries.Should().Equal( + new RemoteFileManifestEntry("files/launcher.big", "ABC123", 42), + new RemoteFileManifestEntry("outside-prefix.big", "DEF456", 7)); + receivedRequest.Should().BeSameAs(request); + } + + 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..16956111 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Clients/ResumableHttpFileDownloaderTests.cs @@ -0,0 +1,1025 @@ +using System; +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.Models; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Clients; + +public sealed class ResumableHttpFileDownloaderTests +{ + [Theory] + [InlineData("RelativeUri")] + [InlineData("UnsupportedScheme")] + [InlineData("MissingDestination")] + public async Task DownloadFileAsync_ThrowsForInvalidRequestAsync(string invalidRequest) + { + 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"; + + Func act = () => downloader.DownloadFileAsync(request, null, CancellationToken.None); + + await act.Should().ThrowAsync().WithParameterName(expectedParameterName); + } + + [Fact] + public async Task DownloadFileAsync_WritesResponseBodyToDestinationAsync() + { + 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(); + + await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.zip"), destinationFilePath), + progress, + CancellationToken.None); + + (await File.ReadAllBytesAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Equal(payload); + progress.Reports.Should().Contain(report => report.BytesDownloaded == payload.Length); + } + + /// + /// The destination is normalized before the transfer starts, so a caller-supplied parent segment resolves to one + /// real folder instead of materializing the segment it walks out of. + /// + [Fact] + public async Task DownloadFileAsync_NormalizesDestinationBeforeCreatingItsFolderAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "a", "..", "nested", "mod.zip"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, Encoding.UTF8.GetBytes("payload"))); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.zip"), destinationFilePath), + null, + CancellationToken.None); + + (await File.ReadAllTextAsync(Path.Combine(testDirectory.Path, "nested", "mod.zip"), TestContext.Current.CancellationToken)) + .Should().Be("payload"); + Directory.Exists(Path.Combine(testDirectory.Path, "a")).Should().BeFalse(); + } + + [Fact] + public async Task DownloadFileAsync_ResumesExistingPartialFileWithRangeRequestAsync() + { + 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, TestContext.Current.CancellationToken); + + 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); + + await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath, 6), + null, + CancellationToken.None); + + handler.RangeHeaders.Should().ContainSingle().Which.Should().Be("bytes=3-"); + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("abcdef"); + } + + /// + /// A range response states the object's real length, so it settles the transfer size even when the caller was + /// working from a stale expectation. + /// + [Fact] + public async Task DownloadFileAsync_ResumedResponseDeclaresTotal_PrefersContentRangeLengthAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + await File.WriteAllTextAsync(destinationFilePath, "abc", TestContext.Current.CancellationToken); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => + { + HttpResponseMessage response = CreateResponse( + HttpStatusCode.PartialContent, + Encoding.UTF8.GetBytes("def")); + response.Content.Headers.ContentRange = new ContentRangeHeaderValue(3, 5, 6); + return response; + }); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + RecordingProgress progress = new(); + + await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath, 7), + progress, + CancellationToken.None); + + handler.RangeHeaders.Should().ContainSingle().Which.Should().Be("bytes=3-"); + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("abcdef"); + progress.Reports.Last().Should().Be(new DownloadProgress(6, 6, 100)); + } + + [Fact] + public async Task DownloadFileAsync_RestartsWhenServerIgnoresRangeRequestAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.zip"); + await File.WriteAllTextAsync(destinationFilePath, "partial", TestContext.Current.CancellationToken); + + byte[] fullPayload = Encoding.UTF8.GetBytes("fresh"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, fullPayload)); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.zip"), destinationFilePath), + null, + CancellationToken.None); + + handler.RangeHeaders.Should().ContainSingle().Which.Should().Be("bytes=7-"); + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("fresh"); + } + + [Fact] + public async Task DownloadFileAsync_RestartsWhenServerReturnsUnexpectedContentRangeAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + await File.WriteAllTextAsync(destinationFilePath, "abc", TestContext.Current.CancellationToken); + + 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); + + await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath, 6), + null, + CancellationToken.None); + + handler.RangeHeaders.Should().Equal("bytes=3-", null); + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("abcdef"); + } + + [Fact] + public async Task DownloadFileAsync_ReturnsExistingCompleteFileWithoutRequestAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + await File.WriteAllTextAsync(destinationFilePath, "ready", TestContext.Current.CancellationToken); + QueueHttpMessageHandler handler = new(); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + RecordingProgress progress = new(); + + await downloader.DownloadFileAsync( + new DownloadFileRequest( + new Uri("https://example.test/mod.big"), + destinationFilePath, + 5), + progress, + CancellationToken.None); + + handler.RangeHeaders.Should().BeEmpty(); + progress.Reports.Should().ContainSingle(report => + report.TotalBytes == 5 && + report.BytesDownloaded == 5 && + report.ProgressPercentage == 100); + } + + [Fact] + public async Task DownloadFileAsync_MissingExpectedEmptyFile_CreatesItFromResponseAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "placeholder.txt"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, [])); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + await downloader.DownloadFileAsync( + new DownloadFileRequest( + new Uri("https://example.test/placeholder.txt"), + destinationFilePath, + 0), + null, + CancellationToken.None); + + handler.RangeHeaders.Should().ContainSingle().Which.Should().BeNull(); + File.Exists(destinationFilePath).Should().BeTrue(); + new FileInfo(destinationFilePath).Length.Should().Be(0); + } + + [Fact] + public async Task DownloadFileAsync_ExistingExpectedEmptyFile_ReturnsWithoutRequestAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "placeholder.txt"); + await File.WriteAllBytesAsync(destinationFilePath, [], TestContext.Current.CancellationToken); + QueueHttpMessageHandler handler = new(); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + await downloader.DownloadFileAsync( + new DownloadFileRequest( + new Uri("https://example.test/placeholder.txt"), + destinationFilePath, + 0), + null, + CancellationToken.None); + + handler.RangeHeaders.Should().BeEmpty(); + File.Exists(destinationFilePath).Should().BeTrue(); + new FileInfo(destinationFilePath).Length.Should().Be(0); + } + + [Fact] + public async Task DownloadFileAsync_ExistingFileLargerThanExpected_RestartsFromZeroAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + await File.WriteAllTextAsync(destinationFilePath, "too-large", TestContext.Current.CancellationToken); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, Encoding.UTF8.GetBytes("fresh"))); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + await downloader.DownloadFileAsync( + new DownloadFileRequest( + new Uri("https://example.test/mod.big"), + destinationFilePath, + 5), + null, + CancellationToken.None); + + handler.RangeHeaders.Should().ContainSingle().Which.Should().BeNull(); + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("fresh"); + } + + [Fact] + public async Task DownloadFileAsync_ReportsProgressWhileContentIsDownloadingAsync() + { + byte[] payload = Encoding.UTF8.GetBytes("abcdef"); + ManualTimeProvider timeProvider = new(); + 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 ChunkStream( + payload, + 2, + () => timeProvider.Advance(TimeSpan.FromMilliseconds(2)))) + }; + response.Content.Headers.ContentLength = payload.Length; + return response; + }); + ResumableHttpFileDownloader downloader = CreateDownloader(handler, timeProvider: timeProvider); + RecordingProgress progress = new(); + + await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath), + progress, + CancellationToken.None); + + 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(); + } + + /// + /// Intermediate progress is rate limited, so a chunked transfer publishes far fewer reports than it reads + /// chunks while the opening and final positions still reach the caller. + /// + [Fact] + public async Task DownloadFileAsync_ThrottlesIntermediateProgressToTheReportIntervalAsync() + { + byte[] payload = Encoding.UTF8.GetBytes("abcdef"); + ManualTimeProvider timeProvider = new(); + 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 ChunkStream( + payload, + 1, + () => timeProvider.Advance(TimeSpan.FromMilliseconds(2)))) + }; + response.Content.Headers.ContentLength = payload.Length; + return response; + }); + ResumableHttpFileDownloader downloader = CreateDownloader( + handler, + progressReportInterval: TimeSpan.FromMilliseconds(10), + timeProvider: timeProvider); + RecordingProgress progress = new(); + + await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath), + progress, + CancellationToken.None); + + long[] reportedBytes = progress.Reports.Select(report => report.BytesDownloaded).ToArray(); + reportedBytes.Should().StartWith(0); + reportedBytes.Should().EndWith(payload.Length); + reportedBytes.Should().HaveCountLessThan(payload.Length); + } + + [Fact] + public async Task DownloadFileAsync_PauseStopsTransferUntilResumedAsync() + { + byte[] payload = Encoding.UTF8.GetBytes("abcdef"); + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + SignalingStreamContent responseContent = new(payload); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = responseContent }); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + PackageDownloadPauseController pauseController = new(); + pauseController.Pause().Should().BeTrue(); + + Task download = downloader.DownloadFileAsync( + new DownloadFileRequest( + new Uri("https://example.test/mod.big"), + destinationFilePath, + PauseController: pauseController), + null, + CancellationToken.None); + await responseContent.BodyRequested.WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + + download.IsCompleted.Should().BeFalse(); + new FileInfo(destinationFilePath).Length.Should().Be(0); + + pauseController.Resume().Should().BeTrue(); + await download.WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + + (await File.ReadAllBytesAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Equal(payload); + } + + [Fact] + public async Task DownloadFileAsync_CancellationInterruptsPausedTransferAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + SignalingStreamContent responseContent = new(Encoding.UTF8.GetBytes("abcdef")); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.OK) { Content = responseContent }); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + PackageDownloadPauseController pauseController = new(); + pauseController.Pause(); + using CancellationTokenSource cancellation = new(); + + Task download = downloader.DownloadFileAsync( + new DownloadFileRequest( + new Uri("https://example.test/mod.big"), + destinationFilePath, + PauseController: pauseController), + null, + cancellation.Token); + await responseContent.BodyRequested.WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + await cancellation.CancelAsync(); + + Func act = () => download; + await act.Should().ThrowAsync(); + } + + [Fact] + public async Task DownloadFileAsync_ResumesBytesWrittenBeforeTransientResponseFailureAsync() + { + byte[] partialPayload = Encoding.UTF8.GetBytes("abc"); + byte[] remainingPayload = Encoding.UTF8.GetBytes("def"); + 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 FailingAfterPayloadStream(partialPayload)) + }); + handler.Enqueue(_ => + { + HttpResponseMessage response = CreateResponse(HttpStatusCode.PartialContent, remainingPayload); + response.Content.Headers.ContentRange = new ContentRangeHeaderValue(3, 5, 6); + return response; + }); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + await downloader.DownloadFileAsync( + new DownloadFileRequest( + new Uri("https://example.test/mod.big"), + destinationFilePath, + 6), + null, + CancellationToken.None); + + handler.RangeHeaders.Should().Equal(null, "bytes=3-"); + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("abcdef"); + } + + [Fact] + public async Task DownloadFileAsync_CancellationRetainsWrittenPrefixForResumeAsync() + { + byte[] partialPayload = Encoding.UTF8.GetBytes("abc"); + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + WaitingAfterPayloadStream responseStream = new(partialPayload); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StreamContent(responseStream) + }); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + using CancellationTokenSource cancellation = new(); + + Task download = downloader.DownloadFileAsync( + new DownloadFileRequest( + new Uri("https://example.test/mod.big"), + destinationFilePath, + 6), + null, + cancellation.Token); + await responseStream.WaitingForCancellation.WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + await cancellation.CancelAsync(); + + Func act = () => download; + await act.Should().ThrowAsync(); + handler.RangeHeaders.Should().ContainSingle().Which.Should().BeNull(); + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("abc"); + } + + [Fact] + public async Task DownloadFileAsync_ThrowsAfterFinalRetriableFailureAsync() + { + 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); + + Func act = () => downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath), + null, + CancellationToken.None); + + 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 DownloadFileAsync_ThrowsWhenDownloadedBytesDoNotMatchExpectedBytesAsync() + { + 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, 1); + + Func act = () => downloader.DownloadFileAsync( + new DownloadFileRequest( + new Uri("https://example.test/mod.big"), + destinationFilePath, + 6), + null, + CancellationToken.None); + + 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 DownloadFileAsync_ThrowsWhenTransferStallsAsync() + { + 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 BlockingStream()) + }); + ResumableHttpFileDownloader downloader = CreateDownloader( + handler, + 1, + TimeSpan.FromMilliseconds(10)); + + Func act = () => downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath), + null, + CancellationToken.None); + + IOException exception = (await act.Should().ThrowAsync() + .WithMessage("Download failed after 1 attempts.")).Which; + exception.InnerException.Should().BeOfType(); + } + + [Fact] + public async Task DownloadFileAsync_WhenResumeIsDisabled_ReplacesExistingFileAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + await File.WriteAllTextAsync(destinationFilePath, "stale", TestContext.Current.CancellationToken); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, Encoding.UTF8.GetBytes("fresh"))); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + await downloader.DownloadFileAsync( + new DownloadFileRequest( + new Uri("https://example.test/mod.big"), + destinationFilePath, + Resume: false), + null, + CancellationToken.None); + + handler.RangeHeaders.Should().ContainSingle().Which.Should().BeNull(); + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("fresh"); + } + + [Fact] + public async Task DownloadFileAsync_PartialResponseForNewFile_UsesResponseLengthAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => + { + HttpResponseMessage response = CreateResponse( + HttpStatusCode.PartialContent, + Encoding.UTF8.GetBytes("abc")); + response.Content.Headers.ContentRange = new ContentRangeHeaderValue(0, 2, 6); + return response; + }); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath), + null, + CancellationToken.None); + + handler.RangeHeaders.Should().ContainSingle().Which.Should().BeNull(); + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("abc"); + } + + [Fact] + public async Task DownloadFileAsync_ResumedResponseWithoutDeclaredTotal_CombinesExistingAndResponseBytesAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + await File.WriteAllTextAsync(destinationFilePath, "abc", TestContext.Current.CancellationToken); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => + { + HttpResponseMessage response = CreateResponse( + HttpStatusCode.PartialContent, + Encoding.UTF8.GetBytes("def")); + response.Content.Headers.ContentRange = new ContentRangeHeaderValue(3, 5); + return response; + }); + RecordingProgress progress = new(); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath), + progress, + CancellationToken.None); + + handler.RangeHeaders.Should().ContainSingle().Which.Should().Be("bytes=3-"); + (await File.ReadAllTextAsync(destinationFilePath, TestContext.Current.CancellationToken)).Should().Be("abcdef"); + progress.Reports.First().Should().Be(new DownloadProgress(6, 3, 50)); + progress.Reports.Last().Should().Be(new DownloadProgress(6, 6, 100)); + } + + [Fact] + public async Task DownloadFileAsync_NonRetriableFailure_DoesNotRetryAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => throw new InvalidOperationException()); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, Encoding.UTF8.GetBytes("unexpected"))); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + Func act = () => downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath), + null, + CancellationToken.None); + + await act.Should().ThrowAsync(); + handler.Requests.Should().ContainSingle(); + File.Exists(destinationFilePath).Should().BeFalse(); + } + + [Fact] + public async Task DownloadFileAsync_PreCanceledToken_DoesNotSendRequestAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, Encoding.UTF8.GetBytes("unexpected"))); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + using CancellationTokenSource cancellation = new(); + await cancellation.CancelAsync(); + + Func act = () => downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath), + null, + cancellation.Token); + + await act.Should().ThrowAsync(); + handler.Requests.Should().BeEmpty(); + File.Exists(destinationFilePath).Should().BeFalse(); + } + + [Fact] + public async Task DownloadFileAsync_EmptyResponse_ReportsIndeterminatePercentageAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => CreateResponse(HttpStatusCode.OK, [])); + RecordingProgress progress = new(); + ResumableHttpFileDownloader downloader = CreateDownloader(handler); + + await downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath), + progress, + CancellationToken.None); + + new FileInfo(destinationFilePath).Length.Should().Be(0); + progress.Reports.Should().NotBeEmpty(); + progress.Reports.Should().OnlyContain(report => + report.TotalBytes == 0 && + report.BytesDownloaded == 0 && + report.ProgressPercentage == null); + } + + /// + /// A server that refuses the resume range answers 416. The bytes already on disk are not the whole + /// file, so the transfer has to fail: reporting it complete would install truncated content as though + /// it were whole, and the launch that follows would run against it. + /// + [Fact] + public async Task DownloadFileAsync_ServerRejectsTheResumeRange_FailsAsync() + { + using TestDirectory testDirectory = new(); + string destinationFilePath = Path.Combine(testDirectory.Path, "mod.big"); + await File.WriteAllTextAsync(destinationFilePath, "abc", TestContext.Current.CancellationToken); + QueueHttpMessageHandler handler = new(); + handler.Enqueue(_ => new HttpResponseMessage(HttpStatusCode.RequestedRangeNotSatisfiable)); + ResumableHttpFileDownloader downloader = CreateDownloader(handler, maxAttempts: 1); + + Func download = () => downloader.DownloadFileAsync( + new DownloadFileRequest(new Uri("https://example.test/mod.big"), destinationFilePath, 6), + null, + CancellationToken.None); + + await download.Should().ThrowAsync(); + } + + private static ResumableHttpFileDownloader CreateDownloader( + QueueHttpMessageHandler handler, + int maxAttempts = 2, + TimeSpan? idleTimeout = null, + TimeSpan? progressReportInterval = null, + TimeProvider? timeProvider = null) + { + HttpClient httpClient = new(handler) + { + Timeout = Timeout.InfiniteTimeSpan + }; + + return new ResumableHttpFileDownloader( + httpClient, + null, + 4, + maxAttempts, + idleTimeout ?? TimeSpan.FromSeconds(5), + progressReportInterval ?? TimeSpan.FromMilliseconds(1), + TimeSpan.FromMilliseconds(1), + timeProvider); + } + + private static HttpResponseMessage CreateResponse(HttpStatusCode statusCode, byte[] payload) + { + return new HttpResponseMessage(statusCode) + { + Content = new ByteArrayContent(payload) + }; + } + + /// + /// Reports when the transfer first reaches for the response body, which happens only after the destination file + /// has been opened, so a paused transfer can be observed without polling. + /// + private sealed class SignalingStreamContent : StreamContent + { + private readonly TaskCompletionSource _bodyRequested = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public SignalingStreamContent(byte[] payload) + : base(new MemoryStream(payload, false)) + { + } + + public Task BodyRequested => _bodyRequested.Task; + + protected override Task CreateContentReadStreamAsync() + { + _bodyRequested.TrySetResult(); + return base.CreateContentReadStreamAsync(); + } + + protected override Task CreateContentReadStreamAsync(CancellationToken cancellationToken) + { + _bodyRequested.TrySetResult(); + return base.CreateContentReadStreamAsync(cancellationToken); + } + } + + private sealed class ChunkStream : Stream + { + private readonly Action _beforeRead; + private readonly int _chunkSize; + private readonly byte[] _payload; + private int _position; + + public ChunkStream(byte[] payload, int chunkSize, Action beforeRead) + { + _payload = payload; + _chunkSize = chunkSize; + _beforeRead = beforeRead; + } + + 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 ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + if (_position >= _payload.Length) + { + return ValueTask.FromResult(0); + } + + cancellationToken.ThrowIfCancellationRequested(); + _beforeRead(); + int bytesToCopy = Math.Min(Math.Min(_chunkSize, buffer.Length), _payload.Length - _position); + _payload.AsMemory(_position, bytesToCopy).CopyTo(buffer); + _position += bytesToCopy; + return ValueTask.FromResult(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 FailingAfterPayloadStream : Stream + { + private readonly byte[] _payload; + private int _position; + + public FailingAfterPayloadStream(byte[] payload) + { + _payload = payload; + } + + 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 ValueTask ReadAsync( + Memory buffer, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + if (_position >= _payload.Length) + { + throw new IOException("response interrupted"); + } + + int bytesToCopy = Math.Min(buffer.Length, _payload.Length - _position); + _payload.AsMemory(_position, bytesToCopy).CopyTo(buffer); + _position += bytesToCopy; + return ValueTask.FromResult(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 WaitingAfterPayloadStream : Stream + { + private readonly byte[] _payload; + private readonly TaskCompletionSource _waitingForCancellation = + new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _position; + + public WaitingAfterPayloadStream(byte[] payload) + { + _payload = payload; + } + + public Task WaitingForCancellation => _waitingForCancellation.Task; + + 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) + { + int bytesToCopy = Math.Min(buffer.Length, _payload.Length - _position); + _payload.AsMemory(_position, bytesToCopy).CopyTo(buffer); + _position += bytesToCopy; + return bytesToCopy; + } + + _waitingForCancellation.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return 0; + } + + 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 BlockingStream : Stream + { + public override bool CanRead => true; + + public override bool CanSeek => false; + + public override bool CanWrite => false; + + public override long Length => throw new NotSupportedException(); + + public override long Position + { + get => throw new NotSupportedException(); + 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) + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return 0; + } + + 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(); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Models/S3RequestDefaultsTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Models/S3RequestDefaultsTests.cs new file mode 100644 index 00000000..45a26a5d --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Models/S3RequestDefaultsTests.cs @@ -0,0 +1,64 @@ +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Models; + +public sealed class S3RequestDefaultsTests +{ + [Fact] + public void CreateManifestRequest_DefaultsToNonSslForLegacyCatalogEndpoints() + { + LauncherContentVersion version = new() + { + S3HostLink = "gen.insave.ovh:9000", + S3BucketName = "mods", + S3FolderName = "folder" + }; + + S3ObjectManifestRequest request = S3CatalogDefaults.CreateManifestRequest(version); + + request.UseSsl.Should().BeFalse(); + } + + /// + /// The keys are written out rather than read back from on purpose. Every + /// catalog entry that predates this fork resolves through these exact values, so they are a backend + /// compatibility contract, and an expectation that asked production for its own constant would follow a + /// changed key instead of reporting it. They are the legacy public credentials the original client already + /// shipped, not application secrets. + /// + [Fact] + public void CreateManifestRequest_UsesPublicCatalogKeysWhenMetadataKeysAreMissing() + { + LauncherContentVersion version = new() + { + S3HostLink = "gen.insave.ovh:9000", + S3BucketName = "mods", + S3FolderName = "folder" + }; + + S3ObjectManifestRequest request = S3CatalogDefaults.CreateManifestRequest(version); + + request.AccessKey.Should().Be("S58TYR9ISEZV8PBP8QG1"); + request.SecretKey.Should().Be("b2RU1oqVU5toJRnb4gODrXX8sBSgoLcHRX6qPWxj"); + } + + [Fact] + public void CreateManifestRequest_PreservesExplicitMetadataKeys() + { + LauncherContentVersion version = new() + { + S3HostLink = "gen.insave.ovh:9000", + S3BucketName = "mods", + S3FolderName = "folder", + S3HostPublicKey = "custom-access", + S3HostSecretKey = "custom-secret" + }; + + S3ObjectManifestRequest request = S3CatalogDefaults.CreateManifestRequest(version); + + request.AccessKey.Should().Be("custom-access"); + request.SecretKey.Should().Be("custom-secret"); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Services/ManagedPackageSourceResolverTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Services/ManagedPackageSourceResolverTests.cs new file mode 100644 index 00000000..db280134 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Services/ManagedPackageSourceResolverTests.cs @@ -0,0 +1,264 @@ +using System; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Services; + +public sealed class ManagedPackageSourceResolverTests +{ + [Theory] + [InlineData(3_355_443_200L, 3_355_443_200L)] + [InlineData(0L, 0L)] + [InlineData(null, null)] + [InlineData(-1L, null)] + public async Task GetTotalBytesAsync_ReturnsDirectFileContentLengthAsync( + long? contentLength, + long? expectedTotalBytes) + { + Uri downloadUri = new("https://example.test/contra.zip"); + StubDownloadFileMetadataReader metadataReader = new((uri, _) => + Task.FromResult(new DownloadFileMetadata(uri, "contra.zip", contentLength))); + RecordingS3ObjectManifestReader manifestReader = new(); + ManagedPackageSourceResolver resolver = CreateResolver(metadataReader, manifestReader); + LauncherContentVersion version = TestLauncherContent.Version( + "Contra", + "009", + simpleDownloadLink: downloadUri.ToString()); + + long? totalBytes = await resolver.GetTotalBytesAsync(version, CancellationToken.None); + + totalBytes.Should().Be(expectedTotalBytes); + metadataReader.RequestCount.Should().Be(1); + manifestReader.Requests.Should().BeEmpty(); + } + + [Fact] + public async Task GetTotalBytesAsync_SumsS3ManifestIncludingEmptyManifestAsync() + { + StubDownloadFileMetadataReader metadataReader = new(); + RecordingS3ObjectManifestReader manifestReader = new(); + manifestReader.Enqueue( + new RemoteFileManifestEntry("Data/one.big", "hash", 10), + new RemoteFileManifestEntry("Data/two.big", "hash", 15)); + manifestReader.Enqueue(); + ManagedPackageSourceResolver resolver = CreateResolver(metadataReader, manifestReader); + + long? populatedSize = await resolver.GetTotalBytesAsync(CreateS3Version("1.0"), CancellationToken.None); + long? emptySize = await resolver.GetTotalBytesAsync(CreateS3Version("2.0"), CancellationToken.None); + + populatedSize.Should().Be(25); + emptySize.Should().Be(0); + metadataReader.RequestCount.Should().Be(0); + manifestReader.Requests.Should().HaveCount(2); + } + + [Fact] + public async Task ResolveAsync_AfterSizeLookup_ReusesSingleFileMetadataAsync() + { + Uri downloadUri = new("https://example.test/contra.zip"); + var metadata = new DownloadFileMetadata(downloadUri, "contra.zip", 42); + StubDownloadFileMetadataReader metadataReader = new((_, _) => Task.FromResult(metadata)); + ManagedPackageSourceResolver resolver = CreateResolver( + metadataReader, + new RecordingS3ObjectManifestReader()); + LauncherContentVersion version = TestLauncherContent.Version( + "Contra", + "009", + simpleDownloadLink: downloadUri.ToString()); + + await resolver.GetTotalBytesAsync(version, CancellationToken.None); + ManagedPackageSourceResolver.PackageSource? source = + await resolver.ResolveAsync(version, CancellationToken.None); + + source.Should().BeOfType() + .Which.Metadata.Should().BeSameAs(metadata); + metadataReader.RequestCount.Should().Be(1); + } + + [Fact] + public async Task ResolveAsync_AfterSizeLookup_ReusesS3ManifestAsync() + { + RemoteFileManifestEntry[] files = + [ + new("Data/one.big", "hash", 10) + ]; + RecordingS3ObjectManifestReader manifestReader = new(); + manifestReader.Enqueue(files); + ManagedPackageSourceResolver resolver = CreateResolver( + new StubDownloadFileMetadataReader(), + manifestReader); + LauncherContentVersion version = CreateS3Version("1.0"); + + await resolver.GetTotalBytesAsync(version, CancellationToken.None); + ManagedPackageSourceResolver.PackageSource? source = + await resolver.ResolveAsync(version, CancellationToken.None); + + source.Should().BeOfType() + .Which.Files.Should().Equal(files); + manifestReader.Requests.Should().ContainSingle(); + } + + [Fact] + public async Task GetTotalBytesAsync_ReturnsUnavailableForUnsupportedSourceAsync() + { + StubDownloadFileMetadataReader metadataReader = new(); + RecordingS3ObjectManifestReader manifestReader = new(); + ManagedPackageSourceResolver resolver = CreateResolver(metadataReader, manifestReader); + LauncherContentVersion version = TestLauncherContent.Version("Manual Mod"); + + long? totalBytes = await resolver.GetTotalBytesAsync(version, CancellationToken.None); + + totalBytes.Should().BeNull(); + metadataReader.RequestCount.Should().Be(0); + manifestReader.Requests.Should().BeEmpty(); + } + + /// + /// An unreachable provider is cached as "size unknown" so a catalog that lists many offline packages does not + /// repeat the same failing request for every card that asks again. + /// + [Fact] + public async Task GetTotalBytesAsync_MapsMetadataFailureToUnavailableAsync() + { + StubDownloadFileMetadataReader metadataReader = new((_, _) => + Task.FromException(new HttpRequestException("Offline"))); + ManagedPackageSourceResolver resolver = CreateResolver(metadataReader, new RecordingS3ObjectManifestReader()); + LauncherContentVersion version = TestLauncherContent.Version( + "Contra", + "009", + simpleDownloadLink: "https://example.test/contra.zip"); + + long? firstAttempt = await resolver.GetTotalBytesAsync(version, CancellationToken.None); + long? secondAttempt = await resolver.GetTotalBytesAsync(version, CancellationToken.None); + + firstAttempt.Should().BeNull(); + secondAttempt.Should().BeNull(); + metadataReader.RequestCount.Should().Be(1); + } + + /// + /// Cancellation is the caller's decision, not the provider's answer, so it must surface and must not be cached + /// as an unknown size that suppresses every later lookup. + /// + [Fact] + public async Task GetTotalBytesAsync_CanceledMetadataRead_LeavesSizeResolvableAsync() + { + StubDownloadFileMetadataReader metadataReader = new((uri, cancellationToken) => + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(new DownloadFileMetadata(uri, "contra.zip", 42)); + }); + ManagedPackageSourceResolver resolver = CreateResolver(metadataReader, new RecordingS3ObjectManifestReader()); + LauncherContentVersion version = TestLauncherContent.Version( + "Contra", + "009", + simpleDownloadLink: "https://example.test/contra.zip"); + using CancellationTokenSource cancellation = new(); + await cancellation.CancelAsync(); + + Func canceledRead = () => resolver.GetTotalBytesAsync(version, cancellation.Token); + + await canceledRead.Should().ThrowAsync(); + (await resolver.GetTotalBytesAsync(version, CancellationToken.None)).Should().Be(42); + metadataReader.RequestCount.Should().Be(1); + } + + [Fact] + public async Task GetTotalBytesAsync_CachesByContentVersionAndSourceMetadataAsync() + { + StubDownloadFileMetadataReader metadataReader = new((uri, _) => + Task.FromResult(new DownloadFileMetadata(uri, "package.zip", 42))); + ManagedPackageSourceResolver resolver = CreateResolver(metadataReader, new RecordingS3ObjectManifestReader()); + LauncherContentVersion firstVersion = TestLauncherContent.Version( + "Contra", + "1.0", + simpleDownloadLink: "https://example.test/contra.zip"); + LauncherContentVersion changedVersion = TestLauncherContent.Version( + "Contra", + "2.0", + simpleDownloadLink: "https://example.test/contra.zip"); + LauncherContentVersion relinkedVersion = TestLauncherContent.Version( + "Contra", + "2.0", + simpleDownloadLink: "https://mirror.example.test/contra.zip"); + + await resolver.GetTotalBytesAsync(firstVersion, CancellationToken.None); + await resolver.GetTotalBytesAsync(firstVersion, CancellationToken.None); + await resolver.GetTotalBytesAsync(changedVersion, CancellationToken.None); + await resolver.GetTotalBytesAsync(relinkedVersion, CancellationToken.None); + + metadataReader.RequestCount.Should().Be(3); + } + + /// + /// The same content version republished from another bucket folder is a different payload, so the cached size + /// from the previous folder must not be reused. + /// + [Fact] + public async Task GetTotalBytesAsync_ChangedS3FolderName_ResolvesTheRelocatedPackageAsync() + { + RecordingS3ObjectManifestReader manifestReader = new(); + manifestReader.Enqueue(new RemoteFileManifestEntry("Data/one.big", "hash", 10)); + manifestReader.Enqueue( + new RemoteFileManifestEntry("Data/one.big", "hash", 10), + new RemoteFileManifestEntry("Data/two.big", "hash", 15)); + ManagedPackageSourceResolver resolver = CreateResolver(new StubDownloadFileMetadataReader(), manifestReader); + LauncherContentVersion version = CreateS3Version("1.0"); + LauncherContentVersion relocatedVersion = TestLauncherContent.S3Version( + "Rise of the Reds", + "1.0", + s3FolderName: "rotr/1.0-mirror"); + + long? originalSize = await resolver.GetTotalBytesAsync(version, CancellationToken.None); + long? relocatedSize = await resolver.GetTotalBytesAsync(relocatedVersion, CancellationToken.None); + + originalSize.Should().Be(10); + relocatedSize.Should().Be(25); + manifestReader.Requests.Should().HaveCount(2); + } + + /// + /// A manifest whose declared sizes cannot add up to a real package size is reported as "size unknown". Wrapping + /// the addition around, or truncating it into a negative , would put a plausible but wrong + /// download size in front of the user. + /// + [Theory] + [InlineData(ulong.MaxValue)] + [InlineData((ulong)long.MaxValue)] + public async Task GetTotalBytesAsync_ManifestSizesExceedARealPackage_ReportsUnavailableAsync(ulong firstEntrySize) + { + RecordingS3ObjectManifestReader manifestReader = new(); + manifestReader.Enqueue( + new RemoteFileManifestEntry("Data/one.big", "hash", firstEntrySize), + new RemoteFileManifestEntry("Data/two.big", "hash", 1)); + ManagedPackageSourceResolver resolver = CreateResolver(new StubDownloadFileMetadataReader(), manifestReader); + + long? totalBytes = await resolver.GetTotalBytesAsync(CreateS3Version("1.0"), CancellationToken.None); + + totalBytes.Should().BeNull(); + } + + private static ManagedPackageSourceResolver CreateResolver( + IDownloadFileMetadataReader metadataReader, + IS3ObjectManifestReader manifestReader) + { + return new ManagedPackageSourceResolver( + metadataReader, + manifestReader, + NullLogger.Instance); + } + + private static LauncherContentVersion CreateS3Version(string version) + { + return TestLauncherContent.S3Version( + "Rise of the Reds", + version, + s3FolderName: $"rotr/{version}"); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Services/Md5FileHashServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Services/Md5FileHashServiceTests.cs new file mode 100644 index 00000000..25a9fc40 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Services/Md5FileHashServiceTests.cs @@ -0,0 +1,39 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Updating.Services; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Services; + +public sealed class Md5FileHashServiceTests +{ + [Fact] + public async Task ComputeMd5HashAsync_ReturnsUppercaseMd5HashAsync() + { + using TestDirectory testDirectory = new(); + string filePath = Path.Combine(testDirectory.Path, "payload.txt"); + await File.WriteAllTextAsync(filePath, "abc", TestContext.Current.CancellationToken); + var service = new Md5FileHashService(); + + string hash = await service.ComputeMd5HashAsync(filePath, CancellationToken.None); + + hash.Should().Be("900150983CD24FB0D6963F7D28E17F72"); + } + + /// + /// Package integrity is decided by comparing this hash against a manifest entry, so a file that cannot be read + /// has to surface the read failure. A swallowed failure would hand the caller a hash it never computed. + /// + [Fact] + public async Task ComputeMd5HashAsync_UnreadableFile_SurfacesTheReadFailureAsync() + { + using TestDirectory testDirectory = new(); + string missingFilePath = Path.Combine(testDirectory.Path, "absent.txt"); + var service = new Md5FileHashService(); + + Func> computeHash = () => service.ComputeMd5HashAsync(missingFilePath, CancellationToken.None); + + await computeHash.Should().ThrowAsync(); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Services/PackageDownloadServiceTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Services/PackageDownloadServiceTests.cs new file mode 100644 index 00000000..8bd92460 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Services/PackageDownloadServiceTests.cs @@ -0,0 +1,467 @@ +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Minio.Exceptions; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Services; + +public sealed class PackageDownloadServiceTests +{ + private static readonly string _shockWave20 = Path.Combine("ShockWave", "2.0"); + + private static readonly string _shockWave30 = Path.Combine("ShockWave", "3.0"); + + public static TheoryData ExpectedFailureCases => + new() + { + { + new TimeoutException("signed URL expired"), + "The remote package provider could not complete the download." + }, + { + new UnexpectedMinioException("presign failed"), + "The remote package provider could not complete the download." + }, + { + new HttpRequestException("offline"), + "The remote package provider could not complete the download." + }, + { + new InvalidDataException(@"bad archive at C:\private\package.zip"), + "The downloaded package could not be validated." + }, + { + new IOException(@"install failed at C:\private\package"), + "The package could not be staged or installed in launcher storage." + }, + { + new UnauthorizedAccessException(@"denied at C:\private\package"), + "The package could not be staged or installed in launcher storage." + } + }; + + [Fact] + public async Task DownloadAsync_UsesLatestVersionLinkAndOwnedPathsForSingleFilePackageAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion oldVersion = CreateSingleFileVersion( + "1.0", + "https://example.test/old.zip"); + LauncherContentVersion latestVersion = CreateSingleFileVersion( + "2.0", + "https://www.dropbox.com/s/package/latest.zip?dl=0"); + LauncherContent modification = TestLauncherContent.From(oldVersion, latestVersion); + RecordingSingleFilePackageUpdater updater = new(); + PackageDownloadService service = CreateService(paths, updater); + PackageDownloadPauseController pauseController = new(); + + PackageDownloadResult result = await service.DownloadAsync( + modification, + latestVersion, + null, + CancellationToken.None, + pauseController); + + result.Status.Should().Be(PackageDownloadStatus.Succeeded); + (DownloadFileMetadata Metadata, PackageUpdatePathSet Paths) request = + updater.Requests.Should().ContainSingle().Which; + request.Metadata.DownloadUri.Should().Be(new Uri("https://www.dropbox.com/s/package/latest.zip?dl=1")); + request.Paths.Should().Be(TestPackageUpdatePaths.Create(paths, _shockWave20, _shockWave20)); + updater.PauseControllers.Should().ContainSingle().Which.Should().BeSameAs(pauseController); + } + + [Fact] + public async Task DownloadAsync_UsesNewGameStorageAfterRuntimeSwitchWithoutRebuildingServiceAsync() + { + using TestDirectory directory = new(); + string executableDirectory = directory.CreateDirectory("Launcher"); + var storagePaths = new LauncherStoragePaths(executableDirectory); + LauncherPaths generalsPaths = storagePaths.CreateGamePaths( + SupportedGame.Generals, + directory.CreateDirectory("GeneralsGame")); + LauncherPaths zeroHourPaths = storagePaths.CreateGamePaths( + SupportedGame.ZeroHour, + directory.CreateDirectory("ZeroHourGame")); + var runtimePaths = new LauncherRuntimePathContext(storagePaths, generalsPaths); + var updater = new RecordingSingleFilePackageUpdater(); + PackageDownloadService service = CreateService(runtimePaths, updater); + LauncherContentVersion version = TestLauncherContent.Version( + "Shared Mod", + sourceKind: ContentSourceKind.ManagedSingleFile, + simpleDownloadLink: "https://example.test/shared.zip"); + LauncherContent modification = TestLauncherContent.From(version); + + await service.DownloadAsync(modification, version, null, CancellationToken.None); + runtimePaths.SwitchActive(zeroHourPaths); + await service.DownloadAsync(modification, version, null, CancellationToken.None); + + updater.Requests.Select(request => request.Paths.InstalledPath.OwnerRoot) + .Should().Equal(generalsPaths.ModsDirectory, zeroHourPaths.ModsDirectory); + updater.Requests.Select(request => request.Paths.TemporaryPath.OwnerRoot) + .Should().Equal(generalsPaths.PackagesDirectory, zeroHourPaths.PackagesDirectory); + } + + /// + /// Reuse comes from the newest installed version, not the newest listed one, so a package with several installed + /// versions still copies from the closest predecessor rather than the oldest install. + /// + [Fact] + public async Task DownloadAsync_UsesS3ManifestAndLatestInstalledVersionPathAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContent modification = TestLauncherContent.From( + TestLauncherContent.S3Version("ShockWave", "1.0", installed: true), + TestLauncherContent.S3Version("ShockWave", "2.0", installed: true), + TestLauncherContent.S3Version("ShockWave", "3.0")); + LauncherContentVersion targetVersion = modification.Versions + .Single(version => version.Version == "3.0"); + RemoteFileManifestEntry[] manifestEntries = + [ + new("Data/file.big", StubFileHashService.MatchingHash, 10) + ]; + RecordingS3ObjectManifestReader manifestReader = new(); + manifestReader.Enqueue(manifestEntries); + RecordingS3PackageUpdater updater = new(); + PackageDownloadService service = CreateService( + paths, + s3PackageUpdater: updater, + manifestReader: manifestReader); + + PackageDownloadResult result = await service.DownloadAsync( + modification, + targetVersion, + null, + CancellationToken.None); + + result.Status.Should().Be(PackageDownloadStatus.Succeeded); + S3ObjectManifestRequest manifestRequest = manifestReader.Requests.Should().ContainSingle().Which; + manifestRequest.Endpoint.Should().Be(TestLauncherContent.S3Host); + manifestRequest.BucketName.Should().Be(TestLauncherContent.S3Bucket); + manifestRequest.Prefix.Should().Be("ShockWave/3.0"); + + S3PackageUpdateRequest updateRequest = updater.UpdateRequests.Should().ContainSingle().Which; + updateRequest.Files.Should().Equal(manifestEntries); + updateRequest.Source.Should().BeSameAs(manifestRequest); + updateRequest.PathSet.Should().Be( + TestPackageUpdatePaths.Create(paths, _shockWave30, _shockWave30, _shockWave20)); + } + + /// + /// Without an installed predecessor there is nothing to copy from, so the updater must be told so rather than + /// handed a folder that does not exist. + /// + [Fact] + public async Task DownloadAsync_NoInstalledVersion_OmitsLatestInstalledPathAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion targetVersion = TestLauncherContent.S3Version("ShockWave", "3.0"); + LauncherContent modification = TestLauncherContent.From(targetVersion); + RecordingS3PackageUpdater updater = new(); + PackageDownloadService service = CreateService(paths, s3PackageUpdater: updater); + + await service.DownloadAsync(modification, targetVersion, null, CancellationToken.None); + + updater.UpdateRequests.Should().ContainSingle() + .Which.PathSet.LatestInstalledPath.Should().BeNull(); + } + + /// + /// Only launcher-managed file kinds carry a reliable MD5 in the remote manifest; hashing anything else would + /// reject files the backend never promised a checksum for. + /// + [Fact] + public async Task DownloadAsync_RequestsHashValidationForManagedGameContentExtensionsAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion targetVersion = TestLauncherContent.S3Version("ShockWave", "3.0"); + LauncherContent modification = TestLauncherContent.From(targetVersion); + RecordingS3PackageUpdater updater = new(); + PackageDownloadService service = CreateService(paths, s3PackageUpdater: updater); + + await service.DownloadAsync(modification, targetVersion, null, CancellationToken.None); + + updater.UpdateRequests.Should().ContainSingle() + .Which.HashCheckedExtensions.Should().BeEquivalentTo( + ".w3d", + LauncherContentFileTypes.BigExtension, + ".bik", + LauncherContentFileTypes.GibExtension, + ".dds", + ".tga", + ".ini", + ".scb", + ".wnd", + ".csf", + ".str"); + } + + [Fact] + public async Task DownloadAsync_ReturnsCanceledWhenCancellationStopsPreCommitWorkAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateSingleFileVersion( + "2.0", + "https://example.test/latest.zip"); + BlockingSingleFilePackageUpdater updater = new(); + PackageDownloadService service = CreateService(paths, updater); + using CancellationTokenSource cancellation = new(); + + Task download = service.DownloadAsync( + TestLauncherContent.From(version), + version, + null, + cancellation.Token); + await updater.Started.Task.WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + cancellation.Cancel(); + PackageDownloadResult result = await download.WaitAsync(TestTimeouts.Wait, TestContext.Current.CancellationToken); + + result.Status.Should().Be(PackageDownloadStatus.Canceled); + } + + [Fact] + public async Task DownloadAsync_KeepsSuccessWhenCancellationArrivesAfterUpdaterCommitAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateSingleFileVersion( + "2.0", + "https://example.test/latest.zip"); + using CancellationTokenSource cancellation = new(); + RecordingSingleFilePackageUpdater updater = new() + { + Update = (_, _, _) => + { + cancellation.Cancel(); + return Task.CompletedTask; + } + }; + PackageDownloadService service = CreateService(paths, updater); + + PackageDownloadResult result = await service.DownloadAsync( + TestLauncherContent.From(version), + version, + null, + cancellation.Token); + + result.Status.Should().Be(PackageDownloadStatus.Succeeded); + } + + [Fact] + public async Task DownloadAsync_TreatsProviderFailureAfterCancellationAsCanceledAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateSingleFileVersion( + "2.0", + "https://example.test/latest.zip"); + using CancellationTokenSource cancellation = new(); + RecordingSingleFilePackageUpdater updater = new() + { + Update = (_, _, _) => + { + cancellation.Cancel(); + throw new IOException("provider aborted after cancellation"); + } + }; + PackageDownloadService service = CreateService(paths, updater); + + PackageDownloadResult result = await service.DownloadAsync( + TestLauncherContent.From(version), + version, + null, + cancellation.Token); + + result.Status.Should().Be(PackageDownloadStatus.Canceled); + } + + [Theory] + // The cases carry Exception instances, which the runner cannot serialize into + // individual rows, so discovery keeps them as one theory rather than enumerating. + [MemberData(nameof(ExpectedFailureCases), DisableDiscoveryEnumeration = true)] + public async Task DownloadAsync_ReturnsSafeRecoverableFailureForExpectedPackageFailuresAsync( + Exception failure, + string expectedMessage) + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateSingleFileVersion( + "2.0", + "https://example.test/latest.zip"); + RecordingSingleFilePackageUpdater updater = new() + { + Update = (_, _, _) => throw failure + }; + PackageDownloadService service = CreateService(paths, updater); + + PackageDownloadResult result = await service.DownloadAsync( + TestLauncherContent.From(version), + version, + null, + CancellationToken.None); + + result.Status.Should().Be(PackageDownloadStatus.RecoverableFailure); + result.Message.Should().Be(expectedMessage); + result.Message.Should().NotContain(@"C:\private"); + } + + [Fact] + public async Task DownloadAsync_ReturnsSafeUnexpectedFailureWithoutLeakingDiagnosticPathAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateSingleFileVersion( + "2.0", + "https://example.test/latest.zip"); + RecordingSingleFilePackageUpdater updater = new() + { + Update = (_, _, _) => throw new InvalidOperationException(@"failure at C:\private\package") + }; + PackageDownloadService service = CreateService(paths, updater); + + PackageDownloadResult result = await service.DownloadAsync( + TestLauncherContent.From(version), + version, + null, + CancellationToken.None); + + result.Status.Should().Be(PackageDownloadStatus.UnexpectedFailure); + result.Message.Should().Be("An unexpected package download error occurred."); + } + + [Theory] + [InlineData(ContentSourceKind.UnknownLegacy)] + [InlineData(ContentSourceKind.Manual)] + public async Task DownloadAsync_RejectsSourcesThatAreNotLauncherManagedAsync(ContentSourceKind sourceKind) + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = TestLauncherContent.Version(sourceKind: sourceKind); + RecordingSingleFilePackageUpdater singleFileUpdater = new(); + RecordingS3PackageUpdater s3Updater = new(); + RecordingS3ObjectManifestReader manifestReader = new(); + PackageDownloadService service = CreateService(paths, singleFileUpdater, s3Updater, manifestReader); + + PackageDownloadResult result = await service.DownloadAsync( + TestLauncherContent.From(version), + version, + null, + CancellationToken.None); + + result.Status.Should().Be(PackageDownloadStatus.UnexpectedFailure); + singleFileUpdater.Requests.Should().BeEmpty(); + manifestReader.Requests.Should().BeEmpty(); + s3Updater.UpdateRequests.Should().BeEmpty(); + } + + [Fact] + public async Task DownloadAsync_SerializesConcurrentProgressWithoutRegressionAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths paths = TestLauncherPaths.Create(testDirectory); + LauncherContentVersion version = CreateSingleFileVersion( + "2.0", + "https://example.test/latest.zip"); + RecordingSingleFilePackageUpdater updater = new() + { + Update = async (_, progress, _) => + { + await Task.WhenAll( + Task.Run(() => progress!.Report(new PackageUpdateProgress(100, 80, 80, "a"))), + Task.Run(() => progress!.Report(new PackageUpdateProgress(100, 20, 20, "b"))), + Task.Run(() => progress!.Report(new PackageUpdateProgress(100, 120, 120, "c")))); + } + }; + PackageDownloadService service = CreateService(paths, updater); + RecordingProgress progress = new(); + + PackageDownloadResult result = await service.DownloadAsync( + TestLauncherContent.From(version), + version, + progress, + CancellationToken.None); + + result.Status.Should().Be(PackageDownloadStatus.Succeeded); + progress.Reports.Should().NotBeEmpty(); + progress.Reports.Select(report => report.BytesRead) + .Should().BeInAscendingOrder(); + progress.Reports.Select(report => report.ProgressPercentage!.Value) + .Should().BeInAscendingOrder() + .And.OnlyContain(value => value >= 0 && value <= 100); + } + + private static PackageDownloadService CreateService( + LauncherPaths paths, + ISingleFilePackageUpdater? singleFilePackageUpdater = null, + IS3PackageUpdater? s3PackageUpdater = null, + IS3ObjectManifestReader? manifestReader = null, + IDownloadFileMetadataReader? metadataReader = null) + { + return CreateService( + TestLauncherPaths.CreateRuntimePathContext(paths), + singleFilePackageUpdater, + s3PackageUpdater, + manifestReader, + metadataReader); + } + + private static PackageDownloadService CreateService( + LauncherRuntimePathContext runtimePathContext, + ISingleFilePackageUpdater? singleFilePackageUpdater = null, + IS3PackageUpdater? s3PackageUpdater = null, + IS3ObjectManifestReader? manifestReader = null, + IDownloadFileMetadataReader? metadataReader = null) + { + var packageSourceResolver = new ManagedPackageSourceResolver( + metadataReader ?? new StubDownloadFileMetadataReader("package.zip", null), + manifestReader ?? new RecordingS3ObjectManifestReader(), + NullLogger.Instance); + return new PackageDownloadService( + singleFilePackageUpdater ?? new RecordingSingleFilePackageUpdater(), + s3PackageUpdater ?? new RecordingS3PackageUpdater(), + packageSourceResolver, + runtimePathContext, + NullLogger.Instance); + } + + private static LauncherContentVersion CreateSingleFileVersion(string version, string downloadLink) + { + return TestLauncherContent.Version( + "ShockWave", + version, + sourceKind: ContentSourceKind.ManagedSingleFile, + simpleDownloadLink: downloadLink); + } + + private sealed class BlockingSingleFilePackageUpdater : ISingleFilePackageUpdater + { + public TaskCompletionSource Started { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public async Task UpdateAsync( + DownloadFileMetadata metadata, + PackageUpdatePathSet paths, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null) + { + Started.TrySetResult(true); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Services/S3PackageUpdaterBehaviorTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Services/S3PackageUpdaterBehaviorTests.cs new file mode 100644 index 00000000..746085c3 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Services/S3PackageUpdaterBehaviorTests.cs @@ -0,0 +1,861 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Services; + +public sealed class S3PackageUpdaterBehaviorTests +{ + [Fact] + public async Task UpdateAsync_CopiesMatchingFilesFromLatestAndSkipsDownloadAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory), "latest"); + string latestFilePath = Path.Combine(paths.LatestInstalledPath!.FullPath, "Data", "readme.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(latestFilePath)!); + await File.WriteAllTextAsync(latestFilePath, "payload", TestContext.Current.CancellationToken); + + RecordingFileDownloader downloader = new(); + S3PackageUpdater updater = CreateUpdater(downloader, new StubFileHashService()); + RecordingProgress progress = new(); + + await updater.UpdateAsync( + CreateRequest( + paths, + new RemoteFileManifestEntry( + "Data/readme.txt", + StubFileHashService.MatchingHash, + (ulong)new FileInfo(latestFilePath).Length)), + progress, + CancellationToken.None); + + downloader.Requests.Should().BeEmpty(); + (await File.ReadAllTextAsync(Path.Combine(paths.InstalledPath.FullPath, "Data", "readme.txt"), TestContext.Current.CancellationToken)) + .Should().Be("payload"); + progress.Reports.Should().Contain(report => report.FileName == null); + } + + [Theory] + [InlineData("payload", StubFileHashService.MismatchedHash)] + [InlineData("stale", StubFileHashService.MatchingHash)] + public async Task UpdateAsync_LatestFileFailingIntegrity_DownloadsReplacementAsync( + string latestContents, + string latestHash) + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory), "latest"); + string latestFilePath = Path.Combine(paths.LatestInstalledPath!.FullPath, "Data", "readme.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(latestFilePath)!); + await File.WriteAllTextAsync(latestFilePath, latestContents, TestContext.Current.CancellationToken); + + RecordingFileDownloader downloader = new(); + S3PackageUpdater updater = CreateUpdater( + downloader, + new StubFileHashService + { + HashForPath = path => string.Equals(path, latestFilePath, StringComparison.OrdinalIgnoreCase) + ? latestHash + : StubFileHashService.MatchingHash + }); + + await updater.UpdateAsync( + CreateRequest( + paths, + new RemoteFileManifestEntry("Data/readme.txt", StubFileHashService.MatchingHash, 7)), + null, + CancellationToken.None); + + downloader.Requests.Should().ContainSingle(); + (await File.ReadAllBytesAsync(Path.Combine(paths.InstalledPath.FullPath, "Data", "readme.txt"), TestContext.Current.CancellationToken)) + .Should().Equal(CreatePayload(7)); + } + + [Fact] + public async Task UpdateAsync_ReusesInstalledGibVariantForBigManifestEntryAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory), "latest"); + string latestFilePath = Path.Combine(paths.LatestInstalledPath!.FullPath, "Data", "archive.gib"); + Directory.CreateDirectory(Path.GetDirectoryName(latestFilePath)!); + await File.WriteAllTextAsync(latestFilePath, "payload", TestContext.Current.CancellationToken); + + RecordingFileDownloader downloader = new(); + S3PackageUpdater updater = CreateUpdater(downloader, new StubFileHashService()); + + await updater.UpdateAsync( + CreateRequest( + paths, + new RemoteFileManifestEntry( + "Data/archive.big", + StubFileHashService.MatchingHash, + (ulong)new FileInfo(latestFilePath).Length)), + null, + CancellationToken.None); + + downloader.Requests.Should().BeEmpty(); + (await File.ReadAllTextAsync(Path.Combine(paths.InstalledPath.FullPath, "Data", "archive.gib"), TestContext.Current.CancellationToken)) + .Should().Be("payload"); + File.Exists(Path.Combine(paths.InstalledPath.FullPath, "Data", "archive.big")).Should().BeFalse(); + } + + /// + /// Files reused from an earlier version still count towards the package's size and progress, so the reported + /// total is what the user is actually installing rather than only the part that crossed the network. + /// + [Fact] + public async Task UpdateAsync_ReportsWholePackageBytesWhenLatestFilesAreReusedAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory), "latest"); + string latestFilePath = Path.Combine(paths.LatestInstalledPath!.FullPath, "Data", "reused.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(latestFilePath)!); + await File.WriteAllBytesAsync(latestFilePath, CreatePayload(660), TestContext.Current.CancellationToken); + + RecordingFileDownloader downloader = new(); + S3PackageUpdater updater = CreateUpdater(downloader, new StubFileHashService()); + RecordingProgress progress = new(); + + await updater.UpdateAsync( + CreateRequest( + paths, + new RemoteFileManifestEntry("Data/reused.txt", StubFileHashService.MatchingHash, 660), + new RemoteFileManifestEntry("Data/missing.txt", StubFileHashService.MatchingHash, 20)), + progress, + CancellationToken.None); + + downloader.Requests.Should().ContainSingle(); + progress.Reports.Should().ContainSingle(); + progress.Reports[0].TotalBytes.Should().Be(680); + progress.Reports[0].BytesRead.Should().Be(680); + progress.Reports[0].ProgressPercentage.Should().Be(100); + } + + /// + /// A resumed transfer reports against the whole package: the 5 bytes already staged count as progress rather + /// than shrinking the total, so the bar continues from where it stopped instead of restarting at zero. + /// + [Fact] + public async Task UpdateAsync_ReportsWholePackageBytesForPartialStagedDownloadAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + string partialFilePath = Path.Combine(paths.TemporaryPath.FullPath, "Data", "missing.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(partialFilePath)!); + await File.WriteAllBytesAsync(partialFilePath, CreatePayload(5), TestContext.Current.CancellationToken); + + RecordingFileDownloader downloader = new(); + S3PackageUpdater updater = CreateUpdater(downloader, new StubFileHashService()); + RecordingProgress progress = new(); + + await updater.UpdateAsync( + CreateRequest( + paths, + new RemoteFileManifestEntry("Data/missing.txt", StubFileHashService.MatchingHash, 20)), + progress, + CancellationToken.None); + + 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_RejectsManifestPathOutsideTemporaryFolderAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths launcherPaths = TestLauncherPaths.Create(testDirectory); + PackageUpdatePathSet paths = CreatePackagePaths(launcherPaths); + S3PackageUpdater updater = CreateUpdater(new RecordingFileDownloader(), new StubFileHashService()); + + Func act = async () => await updater.UpdateAsync( + CreateRequest( + paths, + new RemoteFileManifestEntry("../escape.txt", StubFileHashService.MatchingHash, 1)), + null, + CancellationToken.None); + + await act.Should().ThrowAsync(); + File.Exists(Path.Combine(launcherPaths.PackagesDirectory, "escape.txt")).Should().BeFalse(); + } + + [Fact] + public async Task UpdateAsync_PrunesStaleTemporaryFilesBeforeInstallingAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths launcherPaths = TestLauncherPaths.Create(testDirectory); + PackageUpdatePathSet paths = TestPackageUpdatePaths.Create( + launcherPaths, + Path.Combine("NProject Mod", "2.11"), + "installed"); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + await File.WriteAllTextAsync(Path.Combine(paths.TemporaryPath.FullPath, "stale.txt"), "stale", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(Path.Combine(paths.TemporaryPath.FullPath, "readme.txt"), "payload", TestContext.Current.CancellationToken); + + S3PackageUpdater updater = CreateUpdater(new RecordingFileDownloader(), new StubFileHashService()); + + await updater.UpdateAsync( + CreateRequest( + paths, + new RemoteFileManifestEntry("readme.txt", StubFileHashService.MatchingHash, 7)), + null, + CancellationToken.None); + + File.Exists(Path.Combine(paths.InstalledPath.FullPath, "stale.txt")).Should().BeFalse(); + (await File.ReadAllTextAsync(Path.Combine(paths.InstalledPath.FullPath, "readme.txt"), TestContext.Current.CancellationToken)) + .Should().Be("payload"); + Directory.Exists(launcherPaths.PackagesDirectory).Should().BeFalse(); + Directory.Exists(launcherPaths.TempDirectory).Should().BeTrue(); + } + + [Fact] + public async Task UpdateAsync_RemovesUnsafeStagingLinkWithoutDeletingTargetAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + string outsidePath = testDirectory.CreateDirectory("outside"); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + await File.WriteAllTextAsync(Path.Combine(paths.TemporaryPath.FullPath, "readme.txt"), "payload", TestContext.Current.CancellationToken); + string outsideFile = Path.Combine(outsidePath, "outside.txt"); + await File.WriteAllTextAsync(outsideFile, "outside", TestContext.Current.CancellationToken); + ReparsePointTestSupport.CreateDirectoryJunction( + Path.Combine(paths.TemporaryPath.FullPath, "linked"), + outsidePath); + + S3PackageUpdater updater = CreateUpdater(new RecordingFileDownloader(), new StubFileHashService()); + + await updater.UpdateAsync( + CreateRequest( + paths, + new RemoteFileManifestEntry("readme.txt", StubFileHashService.MatchingHash, 7)), + null, + CancellationToken.None); + + Directory.Exists(Path.Combine(paths.InstalledPath.FullPath, "linked")).Should().BeFalse(); + (await File.ReadAllTextAsync(outsideFile, TestContext.Current.CancellationToken)).Should().Be("outside"); + } + + /// + /// Cancellation between the transfer and the install must leave the installed folder untouched and keep the + /// staged bytes, so the next attempt resumes instead of fetching the package again. + /// + [Fact] + public async Task UpdateAsync_CancellationAfterDownload_KeepsStagedFileAndSkipsInstallAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + using CancellationTokenSource cancellation = new(); + RecordingFileDownloader downloader = new() + { + Handler = async (request, _) => + { + Directory.CreateDirectory(Path.GetDirectoryName(request.DestinationFilePath)!); + await File.WriteAllBytesAsync( + request.DestinationFilePath, + CreatePayload(5), + CancellationToken.None); + await cancellation.CancelAsync(); + } + }; + S3PackageUpdater updater = CreateUpdater(downloader, new StubFileHashService()); + + Func update = () => updater.UpdateAsync( + CreateRequest( + paths, + new RemoteFileManifestEntry("Data/readme.txt", StubFileHashService.MatchingHash, 5)), + null, + cancellation.Token); + + await update.Should().ThrowAsync(); + Directory.Exists(paths.InstalledPath.FullPath).Should().BeFalse(); + (await File.ReadAllBytesAsync(Path.Combine(paths.TemporaryPath.FullPath, "Data", "readme.txt"), TestContext.Current.CancellationToken)) + .Should().Equal(CreatePayload(5)); + } + + [Theory] + [InlineData("Data/readme.txt")] + [InlineData(@"Data\readme.txt")] + public async Task RepairFilesAsync_DownloadsSelectedModifiedFileInPlaceAsync(string manifestFileName) + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + string staleFilePath = Path.Combine(paths.InstalledPath.FullPath, "Data", "readme.txt"); + string keepFilePath = Path.Combine(paths.InstalledPath.FullPath, "Data", "keep.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(staleFilePath)!); + await File.WriteAllTextAsync(staleFilePath, "stale", TestContext.Current.CancellationToken); + await File.WriteAllTextAsync(keepFilePath, "keep", TestContext.Current.CancellationToken); + + RecordingFileDownloader downloader = new(); + StubFileHashService hashService = new() + { + HashForPath = path => File.ReadAllBytes(path).All(value => value == (byte)'x') + ? StubFileHashService.MatchingHash + : StubFileHashService.MismatchedHash + }; + S3PackageUpdater updater = CreateUpdater(downloader, hashService); + RecordingProgress progress = new(); + + await updater.RepairFilesAsync( + CreateRepairRequest( + paths.InstalledPath, + new RemoteFileManifestEntry(manifestFileName, StubFileHashService.MatchingHash, 5)), + progress, + CancellationToken.None); + + DownloadFileRequest request = downloader.Requests.Should().ContainSingle().Which; + request.DestinationFilePath.Should().Be(staleFilePath); + request.SourceUri.AbsolutePath.Should().Be("/mods/folder/Data/readme.txt"); + (await File.ReadAllBytesAsync(staleFilePath, TestContext.Current.CancellationToken)).Should().AllBeEquivalentTo((byte)'x'); + (await File.ReadAllTextAsync(keepFilePath, TestContext.Current.CancellationToken)).Should().Be("keep"); + progress.Reports.Should().ContainSingle(report => + report.TotalBytes == 5 && + report.BytesRead == 5 && + report.ProgressPercentage == 100); + } + + /// + /// S3 publishes ETags while the launcher computes MD5 sums, and the two differ only in letter case; treating + /// that as corruption would re-download a file that is already correct. + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task RepairFilesAsync_HashLetterCaseDiffers_SkipsDownloadAsync(bool manifestHashIsLowercase) + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + string installedFilePath = Path.Combine(paths.InstalledPath.FullPath, "Data", "readme.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(installedFilePath)!); + await File.WriteAllBytesAsync(installedFilePath, CreatePayload(5), TestContext.Current.CancellationToken); + string lowercaseHash = StubFileHashService.MatchingHash.ToLowerInvariant(); + string manifestHash = manifestHashIsLowercase ? lowercaseHash : StubFileHashService.MatchingHash; + string computedHash = manifestHashIsLowercase ? StubFileHashService.MatchingHash : lowercaseHash; + + RecordingFileDownloader downloader = new(); + S3PackageUpdater updater = CreateUpdater( + downloader, + new StubFileHashService { HashForPath = _ => computedHash }); + + await updater.RepairFilesAsync( + CreateRepairRequest( + paths.InstalledPath, + new RemoteFileManifestEntry("Data/readme.txt", manifestHash, 5)), + null, + CancellationToken.None); + + downloader.Requests.Should().BeEmpty(); + (await File.ReadAllBytesAsync(installedFilePath, TestContext.Current.CancellationToken)).Should().Equal(CreatePayload(5)); + } + + [Fact] + public async Task RepairFilesAsync_RejectsLinkedInstalledTreeWithoutMutatingTargetAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + string outsidePath = testDirectory.CreateDirectory("outside"); + string outsideFilePath = Path.Combine(outsidePath, "readme.txt"); + await File.WriteAllTextAsync(outsideFilePath, "outside", TestContext.Current.CancellationToken); + ReparsePointTestSupport.CreateDirectoryJunction(paths.InstalledPath.FullPath, outsidePath); + RecordingFileDownloader downloader = new(); + S3PackageUpdater updater = CreateUpdater(downloader, new StubFileHashService()); + + Func repair = () => updater.RepairFilesAsync( + CreateRepairRequest( + paths.InstalledPath, + new RemoteFileManifestEntry("readme.txt", StubFileHashService.MatchingHash, 5)), + null, + CancellationToken.None); + + await repair.Should().ThrowAsync() + .WithMessage("*reparse point*"); + downloader.Requests.Should().BeEmpty(); + (await File.ReadAllTextAsync(outsideFilePath, TestContext.Current.CancellationToken)).Should().Be("outside"); + } + + [Fact] + public async Task RepairFilesAsync_DownloadFailureRetainsPartialFileForResumeAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + string destinationPath = Path.Combine(paths.InstalledPath.FullPath, "Data", "readme.txt"); + RecordingFileDownloader downloader = new() + { + Handler = async (request, cancellationToken) => + { + Directory.CreateDirectory(Path.GetDirectoryName(request.DestinationFilePath)!); + await File.WriteAllBytesAsync(request.DestinationFilePath, [1, 2], cancellationToken); + throw new IOException("connection interrupted"); + } + }; + S3PackageUpdater updater = CreateUpdater(downloader, new StubFileHashService()); + + Func repair = () => updater.RepairFilesAsync( + CreateRepairRequest( + paths.InstalledPath, + new RemoteFileManifestEntry("Data/readme.txt", StubFileHashService.MatchingHash, 5)), + null, + CancellationToken.None); + + await repair.Should().ThrowAsync() + .WithMessage("connection interrupted"); + DownloadFileRequest request = downloader.Requests.Should().ContainSingle().Which; + request.Resume.Should().BeTrue(); + (await File.ReadAllBytesAsync(destinationPath, TestContext.Current.CancellationToken)).Should().Equal([1, 2]); + } + + [Fact] + public async Task RepairFilesAsync_ResumesExistingPartialInstalledFileAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + string destinationPath = Path.Combine(paths.InstalledPath.FullPath, "Data", "readme.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + await File.WriteAllBytesAsync(destinationPath, [1, 2], TestContext.Current.CancellationToken); + RecordingFileDownloader downloader = new() + { + Handler = async (request, cancellationToken) => + { + request.Resume.Should().BeTrue(); + (await File.ReadAllBytesAsync(request.DestinationFilePath, cancellationToken)) + .Should().Equal([1, 2]); + await using FileStream stream = new( + request.DestinationFilePath, + FileMode.Append, + FileAccess.Write, + FileShare.Read, + 4096, + FileOptions.Asynchronous); + await stream.WriteAsync(new byte[] { 3, 4, 5 }, cancellationToken); + } + }; + S3PackageUpdater updater = CreateUpdater(downloader, new StubFileHashService()); + + await updater.RepairFilesAsync( + CreateRepairRequest( + paths.InstalledPath, + new RemoteFileManifestEntry("Data/readme.txt", StubFileHashService.MatchingHash, 5)), + null, + CancellationToken.None); + + downloader.Requests.Should().ContainSingle(); + (await File.ReadAllBytesAsync(destinationPath, TestContext.Current.CancellationToken)).Should().Equal([1, 2, 3, 4, 5]); + } + + [Fact] + public async Task RepairFilesAsync_ExactSizeHashMismatch_RemovesStaleFileBeforeDownloadAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + string destinationPath = Path.Combine(paths.InstalledPath.FullPath, "Data", "readme.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + await File.WriteAllBytesAsync(destinationPath, [1, 2, 3, 4, 5], TestContext.Current.CancellationToken); + bool staleFilePresentWhenDownloadStarted = false; + RecordingFileDownloader downloader = new() + { + Handler = async (request, cancellationToken) => + { + staleFilePresentWhenDownloadStarted = File.Exists(request.DestinationFilePath); + await File.WriteAllBytesAsync( + request.DestinationFilePath, + CreatePayload(5), + cancellationToken); + } + }; + S3PackageUpdater updater = CreateUpdater( + downloader, + new StubFileHashService + { + HashForPath = path => File.ReadAllBytes(path).All(value => value == (byte)'x') + ? StubFileHashService.MatchingHash + : StubFileHashService.MismatchedHash + }); + + await updater.RepairFilesAsync( + CreateRepairRequest( + paths.InstalledPath, + new RemoteFileManifestEntry("Data/readme.txt", StubFileHashService.MatchingHash, 5)), + null, + CancellationToken.None); + + staleFilePresentWhenDownloadStarted.Should().BeFalse(); + downloader.Requests.Should().ContainSingle(); + (await File.ReadAllBytesAsync(destinationPath, TestContext.Current.CancellationToken)).Should().Equal(CreatePayload(5)); + } + + [Theory] + [InlineData(true, 20, 19)] + [InlineData(false, 35, 34)] + public async Task RepairFilesAsync_ResumeProgress_DistinguishesAcceptedRangeFromRestartAsync( + bool serverAcceptedResume, + long expectedTotalBytes, + long expectedIntermediateBytes) + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + string destinationPath = Path.Combine(paths.InstalledPath.FullPath, "Data", "readme.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + await File.WriteAllBytesAsync(destinationPath, CreatePayload(15), TestContext.Current.CancellationToken); + RecordingFileDownloader downloader = new() + { + ProgressHandler = async (request, progress, cancellationToken) => + { + progress?.Report(new DownloadProgress( + 20, + serverAcceptedResume ? 15 : 0, + serverAcceptedResume ? 75 : 0)); + if (serverAcceptedResume) + { + await using FileStream stream = new( + request.DestinationFilePath, + FileMode.Append, + FileAccess.Write, + FileShare.Read, + 4096, + FileOptions.Asynchronous); + await stream.WriteAsync(CreatePayload(5), cancellationToken); + } + else + { + await File.WriteAllBytesAsync( + request.DestinationFilePath, + CreatePayload(20), + cancellationToken); + } + + progress?.Report(new DownloadProgress(20, 20, 100)); + } + }; + S3PackageUpdater updater = CreateUpdater(downloader, new StubFileHashService()); + RecordingProgress progress = new(); + + await updater.RepairFilesAsync( + CreateRepairRequest( + paths.InstalledPath, + new RemoteFileManifestEntry("Data/readme.txt", StubFileHashService.MatchingHash, 20)), + progress, + CancellationToken.None); + + progress.Reports.Select(report => report.TotalBytes) + .Should().OnlyContain(totalBytes => totalBytes == expectedTotalBytes); + progress.Reports.Select(report => report.BytesRead) + .Should().Equal(15, expectedIntermediateBytes, expectedTotalBytes); + progress.Reports.Take(progress.Reports.Count - 1) + .Should().OnlyContain(report => report.ProgressPercentage < 100); + progress.Reports[^1].ProgressPercentage.Should().Be(100); + } + + [Fact] + public async Task RepairFilesAsync_SizeOnlyFileMatches_SkipsDownloadAndHashAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + string destinationPath = Path.Combine(paths.InstalledPath.FullPath, "Data", "asset.bin"); + Directory.CreateDirectory(Path.GetDirectoryName(destinationPath)!); + await File.WriteAllBytesAsync(destinationPath, [1, 2, 3, 4, 5], TestContext.Current.CancellationToken); + RecordingFileDownloader downloader = new(); + S3PackageUpdater updater = CreateUpdater( + downloader, + new StubFileHashService + { + HashForPath = _ => throw new InvalidOperationException( + "Size-only package files must not be hashed.") + }); + + await updater.RepairFilesAsync( + CreateRepairRequest( + paths.InstalledPath, + new RemoteFileManifestEntry("Data/asset.bin", string.Empty, 5)), + null, + CancellationToken.None); + + downloader.Requests.Should().BeEmpty(); + (await File.ReadAllBytesAsync(destinationPath, TestContext.Current.CancellationToken)).Should().Equal([1, 2, 3, 4, 5]); + } + + [Fact] + public async Task RepairFilesAsync_MoreThanSixFiles_CompletesEveryDownloadAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + RemoteFileManifestEntry[] files = Enumerable.Range(0, 7) + .Select(index => new RemoteFileManifestEntry($"Data/file-{index}.bin", string.Empty, 1)) + .ToArray(); + RecordingFileDownloader downloader = new(); + S3PackageUpdater updater = CreateUpdater(downloader, new StubFileHashService()); + using CancellationTokenSource cancellation = new(TestTimeouts.Wait); + + await updater.RepairFilesAsync( + CreateRepairRequest(paths.InstalledPath, files), + null, + cancellation.Token); + + downloader.Requests.Should().HaveCount(files.Length); + foreach (RemoteFileManifestEntry file in files) + { + File.Exists(Path.Combine(paths.InstalledPath.FullPath, file.FileName)).Should().BeTrue(); + } + } + + [Fact] + public async Task RepairFilesAsync_CancellationRetainsPartialFileForResumeAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + string destinationPath = Path.Combine(paths.InstalledPath.FullPath, "Data", "readme.txt"); + using CancellationTokenSource cancellation = new(); + RecordingFileDownloader downloader = new() + { + Handler = async (request, cancellationToken) => + { + Directory.CreateDirectory(Path.GetDirectoryName(request.DestinationFilePath)!); + await File.WriteAllBytesAsync(request.DestinationFilePath, [1, 2], CancellationToken.None); + await cancellation.CancelAsync(); + cancellationToken.ThrowIfCancellationRequested(); + } + }; + S3PackageUpdater updater = CreateUpdater(downloader, new StubFileHashService()); + + Func repair = () => updater.RepairFilesAsync( + CreateRepairRequest( + paths.InstalledPath, + new RemoteFileManifestEntry("Data/readme.txt", StubFileHashService.MatchingHash, 5)), + null, + cancellation.Token); + + await repair.Should().ThrowAsync(); + downloader.Requests.Should().ContainSingle().Which.Resume.Should().BeTrue(); + (await File.ReadAllBytesAsync(destinationPath, TestContext.Current.CancellationToken)).Should().Equal([1, 2]); + } + + [Fact] + public async Task RepairFilesAsync_RepeatedHashMismatchFailsAfterThreeAttemptsAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + RecordingFileDownloader downloader = new(); + S3PackageUpdater updater = CreateUpdater( + downloader, + new StubFileHashService { HashForPath = _ => StubFileHashService.MismatchedHash }); + + Func repair = () => updater.RepairFilesAsync( + CreateRepairRequest( + paths.InstalledPath, + new RemoteFileManifestEntry("Data/readme.txt", StubFileHashService.MatchingHash, 5)), + null, + CancellationToken.None); + + await repair.Should().ThrowAsync(); + downloader.Requests.Should().HaveCount(3); + } + + [Fact] + public async Task RepairFilesAsync_BigFileHashRetry_RemovesConvertedFailureBeforeRetryAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + string bigPath = Path.Combine(paths.InstalledPath.FullPath, "Data", "archive.big"); + string gibPath = Path.Combine(paths.InstalledPath.FullPath, "Data", "archive.gib"); + Directory.CreateDirectory(Path.GetDirectoryName(bigPath)!); + await File.WriteAllBytesAsync(bigPath, CreatePayload(2), TestContext.Current.CancellationToken); + int downloadAttempt = 0; + bool failedVariantsRemovedBeforeRetry = false; + RecordingFileDownloader downloader = new() + { + ProgressHandler = async (request, progress, cancellationToken) => + { + downloadAttempt++; + if (downloadAttempt == 1) + { + progress?.Report(new DownloadProgress(5, 2, 40)); + await using FileStream stream = new( + request.DestinationFilePath, + FileMode.Append, + FileAccess.Write, + FileShare.Read, + 4096, + FileOptions.Asynchronous); + await stream.WriteAsync(CreatePayload(3), cancellationToken); + } + else + { + failedVariantsRemovedBeforeRetry = + !File.Exists(bigPath) && !File.Exists(gibPath); + progress?.Report(new DownloadProgress(5, 0, 0)); + Directory.CreateDirectory(Path.GetDirectoryName(request.DestinationFilePath)!); + await File.WriteAllBytesAsync( + request.DestinationFilePath, + CreatePayload(5), + cancellationToken); + } + + progress?.Report(new DownloadProgress(5, 5, 100)); + } + }; + int hashAttempt = 0; + S3PackageUpdater updater = CreateUpdater( + downloader, + new StubFileHashService + { + HashForPath = _ => Interlocked.Increment(ref hashAttempt) == 1 + ? StubFileHashService.MismatchedHash + : StubFileHashService.MatchingHash + }); + RecordingProgress progress = new(); + + await updater.RepairFilesAsync( + CreateRepairRequest( + paths.InstalledPath, + new RemoteFileManifestEntry("Data/archive.big", StubFileHashService.MatchingHash, 5)), + progress, + CancellationToken.None); + + downloader.Requests.Should().HaveCount(2); + failedVariantsRemovedBeforeRetry.Should().BeTrue(); + progress.Reports[^1].TotalBytes.Should().Be(10); + progress.Reports[^1].BytesRead.Should().Be(10); + progress.Reports[^1].ProgressPercentage.Should().Be(100); + File.Exists(bigPath).Should().BeFalse(); + (await File.ReadAllBytesAsync(gibPath, TestContext.Current.CancellationToken)).Should().Equal(CreatePayload(5)); + } + + [Fact] + public async Task UpdateAsync_HashRetry_ReportsMonotonicProgressAndOnlyFinishesAtOneHundredAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + int hashAttempt = 0; + RecordingFileDownloader downloader = new() + { + ProgressHandler = async (request, progress, cancellationToken) => + { + long length = request.ExpectedBytes.GetValueOrDefault(); + Directory.CreateDirectory(Path.GetDirectoryName(request.DestinationFilePath)!); + await File.WriteAllBytesAsync( + request.DestinationFilePath, + CreatePayload(checked((int)length)), + cancellationToken); + progress?.Report(new DownloadProgress(length, length, 100)); + } + }; + S3PackageUpdater updater = CreateUpdater( + downloader, + new StubFileHashService + { + HashForPath = _ => Interlocked.Increment(ref hashAttempt) == 1 + ? StubFileHashService.MismatchedHash + : StubFileHashService.MatchingHash + }); + RecordingProgress progress = new(); + + await updater.UpdateAsync( + CreateRequest( + paths, + new RemoteFileManifestEntry("Data/file.txt", StubFileHashService.MatchingHash, 5)), + progress, + CancellationToken.None); + + progress.Reports.Should().HaveCountGreaterThan(1); + progress.Reports.Select(report => report.ProgressPercentage!.Value) + .Should().BeInAscendingOrder(); + progress.Reports.Take(progress.Reports.Count - 1) + .Should().OnlyContain(report => report.ProgressPercentage < 100); + progress.Reports[^1].ProgressPercentage.Should().Be(100); + progress.Reports[^1].BytesRead.Should().Be(progress.Reports[^1].TotalBytes); + } + + [Theory] + [InlineData("Data/file.big", "data/file.gib")] + [InlineData("Data/file.txt", @"data\file.txt")] + public async Task UpdateAsync_RejectsDuplicateNormalizedManifestDestinationsAsync( + string firstFile, + string secondFile) + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(TestLauncherPaths.Create(testDirectory)); + S3PackageUpdater updater = CreateUpdater(new RecordingFileDownloader(), new StubFileHashService()); + S3PackageUpdateRequest request = CreateRequest( + paths, + new RemoteFileManifestEntry(firstFile, string.Empty, 1), + new RemoteFileManifestEntry(secondFile, string.Empty, 1)); + + Func update = () => updater.UpdateAsync( + request, + null, + CancellationToken.None); + + await update.Should().ThrowAsync() + .WithMessage("*duplicate local file destinations*"); + } + + private static S3PackageUpdater CreateUpdater( + IResumableFileDownloader downloader, + IFileHashService hashService) + { + return new S3PackageUpdater( + downloader, + hashService, + NullLogger.Instance); + } + + private static PackageUpdatePathSet CreatePackagePaths( + LauncherPaths launcherPaths, + string? latestRelativePath = null) + { + return TestPackageUpdatePaths.Create(launcherPaths, "temp", "installed", latestRelativePath); + } + + private static S3PackageUpdateRequest CreateRequest( + PackageUpdatePathSet paths, + params RemoteFileManifestEntry[] files) + { + return new S3PackageUpdateRequest( + files, + CreateSource(), + paths, + CreateHashCheckedExtensions()); + } + + private static S3PackageFileRepairRequest CreateRepairRequest( + OwnedContentPath installedPath, + params RemoteFileManifestEntry[] files) + { + return new S3PackageFileRepairRequest( + files, + CreateSource(), + installedPath, + CreateHashCheckedExtensions()); + } + + private static HashSet CreateHashCheckedExtensions() + { + return new HashSet(StringComparer.OrdinalIgnoreCase) { ".txt", ".big", ".gib" }; + } + + private static S3ObjectManifestRequest CreateSource() + { + return new S3ObjectManifestRequest( + "https://example.test", + "mods", + "folder", + "access", + "secret"); + } + + private static byte[] CreatePayload(int length) + { + byte[] payload = new byte[length]; + Array.Fill(payload, (byte)'x'); + return payload; + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Services/SingleFilePackageUpdaterTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Services/SingleFilePackageUpdaterTests.cs new file mode 100644 index 00000000..e2a8fce6 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Services/SingleFilePackageUpdaterTests.cs @@ -0,0 +1,401 @@ +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Services; + +public sealed class SingleFilePackageUpdaterTests +{ + private static readonly string _versionRelativePath = Path.Combine("NProject Mod", "2.11"); + + [Fact] + public async Task UpdateAsync_ClearsStaleTemporaryFilesBeforeInstallingAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + await File.WriteAllTextAsync(Path.Combine(paths.TemporaryPath.FullPath, "stale.txt"), "stale", TestContext.Current.CancellationToken); + SingleFilePackageUpdater updater = CreateUpdater( + CreateWritingDownloader("payload"), + CreateUnusedArchiveExtractor()); + + await updater.UpdateAsync( + CreateMetadata("readme.txt", 7), + paths, + null, + CancellationToken.None); + + File.Exists(Path.Combine(paths.InstalledPath.FullPath, "stale.txt")).Should().BeFalse(); + (await File.ReadAllTextAsync(Path.Combine(paths.InstalledPath.FullPath, "readme.txt"), TestContext.Current.CancellationToken)) + .Should().Be("payload"); + } + + [Fact] + public async Task UpdateAsync_RemovesEmptyPackageStagingParentsAfterInstallingAsync() + { + using TestDirectory testDirectory = new(); + LauncherPaths launcherPaths = TestLauncherPaths.Create(testDirectory); + PackageUpdatePathSet paths = TestPackageUpdatePaths.Create( + launcherPaths, + _versionRelativePath, + _versionRelativePath); + SingleFilePackageUpdater updater = CreateUpdater( + CreateWritingDownloader("payload"), + CreateUnusedArchiveExtractor()); + + await updater.UpdateAsync( + CreateMetadata("readme.txt", 7), + paths, + null, + CancellationToken.None); + + (await File.ReadAllTextAsync(Path.Combine(paths.InstalledPath.FullPath, "readme.txt"), TestContext.Current.CancellationToken)) + .Should().Be("payload"); + Directory.Exists(launcherPaths.PackagesDirectory).Should().BeFalse(); + Directory.Exists(launcherPaths.TempDirectory).Should().BeTrue(); + } + + [Theory] + [InlineData(".zip")] + [InlineData(".rar")] + [InlineData(".7z")] + public async Task UpdateAsync_ExtractsArchiveDeletesDownloadedArchiveAndInstallsExtractedFilesAsync( + string extension) + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + string archiveFileName = "package" + extension; + RecordingArchiveExtractor archiveExtractor = new() + { + ExtractHandler = destinationDirectory => File.WriteAllText( + Path.Combine(destinationDirectory, "extracted.gib"), + "extracted") + }; + SingleFilePackageUpdater updater = CreateUpdater( + CreateWritingDownloader("archive"), + archiveExtractor); + + await updater.UpdateAsync( + CreateMetadata(archiveFileName, 7), + paths, + null, + CancellationToken.None); + + File.Exists(Path.Combine(paths.InstalledPath.FullPath, archiveFileName)).Should().BeFalse(); + (await File.ReadAllTextAsync(Path.Combine(paths.InstalledPath.FullPath, "extracted.gib"), TestContext.Current.CancellationToken)) + .Should().Be("extracted"); + Path.GetFileName(archiveExtractor.ArchiveFilePath!).Should().Be(archiveFileName); + archiveExtractor.ConvertBigFilesToGib.Should().BeTrue(); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(".")] + [InlineData("..")] + [InlineData("../escape.zip")] + [InlineData(@"nested\escape.zip")] + [InlineData("nested/escape.zip")] + [InlineData("C:escape.zip")] + [InlineData(" update = () => updater.UpdateAsync( + CreateMetadata(fileName, 7), + paths, + null, + CancellationToken.None); + + await update.Should().ThrowAsync() + .WithMessage("*safe direct file name*"); + Directory.Exists(paths.InstalledPath.FullPath).Should().BeFalse(); + File.Exists(Path.Combine(testDirectory.Path, "escape.zip")).Should().BeFalse(); + } + + [Fact] + public async Task UpdateAsync_RejectsLinkedInstalledRootWithoutDeletingTargetAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + string outsidePath = testDirectory.CreateDirectory("outside"); + string outsideFile = Path.Combine(outsidePath, "outside.txt"); + await File.WriteAllTextAsync(outsideFile, "outside", TestContext.Current.CancellationToken); + Directory.CreateDirectory(Path.GetDirectoryName(paths.InstalledPath.FullPath)!); + ReparsePointTestSupport.CreateDirectoryJunction(paths.InstalledPath.FullPath, outsidePath); + SingleFilePackageUpdater updater = CreateUpdater( + CreateWritingDownloader("payload"), + CreateUnusedArchiveExtractor()); + + Func update = () => updater.UpdateAsync( + CreateMetadata("readme.txt", 7), + paths, + null, + CancellationToken.None); + + await update.Should().ThrowAsync(); + (await File.ReadAllTextAsync(outsideFile, TestContext.Current.CancellationToken)).Should().Be("outside"); + } + + /// + /// Cancellation between the transfer and the install must not leave a half-installed package: nothing is moved + /// into place, and the staged bytes stay for the resumed attempt. + /// + [Fact] + public async Task UpdateAsync_CancellationAfterDownload_KeepsStagedFileAndSkipsInstallAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + using CancellationTokenSource cancellation = new(); + RecordingFileDownloader downloader = new() + { + Handler = async (request, _) => + { + await File.WriteAllTextAsync(request.DestinationFilePath, "payload", CancellationToken.None); + await cancellation.CancelAsync(); + } + }; + SingleFilePackageUpdater updater = CreateUpdater( + downloader, + CreateUnusedArchiveExtractor()); + + Func update = () => updater.UpdateAsync( + CreateMetadata("readme.txt", 7), + paths, + null, + cancellation.Token); + + await update.Should().ThrowAsync(); + Directory.Exists(paths.InstalledPath.FullPath).Should().BeFalse(); + (await File.ReadAllTextAsync(Path.Combine(paths.TemporaryPath.FullPath, "readme.txt"), TestContext.Current.CancellationToken)) + .Should().Be("payload"); + } + + /// + /// Package progress is reported against the remote file's declared size while the transfer runs, and the request + /// asks to resume so an interrupted attempt continues instead of refetching bytes already on disk. + /// + [Fact] + public async Task UpdateAsync_ReportsTransferProgressAgainstDeclaredPackageSizeAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + RecordingFileDownloader downloader = new() + { + ProgressHandler = async (request, progress, cancellationToken) => + { + progress?.Report(new DownloadProgress(7, 3, 42.86)); + await File.WriteAllTextAsync(request.DestinationFilePath, "payload", cancellationToken); + progress?.Report(new DownloadProgress(7, 7, 100)); + } + }; + SingleFilePackageUpdater updater = CreateUpdater( + downloader, + CreateUnusedArchiveExtractor()); + RecordingProgress progress = new(); + + await updater.UpdateAsync( + CreateMetadata("readme.txt", 7), + paths, + progress, + CancellationToken.None); + + progress.Reports[0].TotalBytes.Should().Be(7); + progress.Reports[0].BytesRead.Should().Be(3); + progress.Reports[^1].TotalBytes.Should().Be(7); + progress.Reports[^1].BytesRead.Should().Be(7); + progress.Reports[^1].ProgressPercentage.Should().Be(100); + DownloadFileRequest request = downloader.Requests.Should().ContainSingle().Which; + request.Resume.Should().BeTrue(); + request.ExpectedBytes.Should().Be(7); + } + + /// + /// A paused download must not clear the staging folder or start a transfer: the user paused to keep what is + /// already on disk, and resuming has to continue that attempt rather than begin a fresh one. + /// + [Fact] + public async Task UpdateAsync_PausedBeforeStart_DoesNoStagingWorkUntilResumedAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + string stagedFilePath = Path.Combine(paths.TemporaryPath.FullPath, "stale.txt"); + await File.WriteAllTextAsync(stagedFilePath, "stale", TestContext.Current.CancellationToken); + SingleFilePackageUpdater updater = CreateUpdater( + CreateWritingDownloader("payload"), + CreateUnusedArchiveExtractor()); + PackageDownloadPauseController pauseController = new(); + pauseController.Pause(); + + Task update = updater.UpdateAsync( + CreateMetadata("readme.txt", 7), + paths, + null, + CancellationToken.None, + pauseController); + bool stagingUntouchedWhilePaused = File.Exists(stagedFilePath); + pauseController.Resume(); + await update; + + stagingUntouchedWhilePaused.Should().BeTrue(); + (await File.ReadAllTextAsync(Path.Combine(paths.InstalledPath.FullPath, "readme.txt"), TestContext.Current.CancellationToken)) + .Should().Be("payload"); + } + + /// + /// Cancellation between extraction and installation keeps the downloaded archive, so the retry resumes from the + /// bytes already transferred instead of fetching the whole package again. + /// + [Fact] + public async Task UpdateAsync_CanceledDuringExtraction_KeepsTheDownloadedArchiveAndSkipsInstallAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + using CancellationTokenSource cancellation = new(); + RecordingArchiveExtractor archiveExtractor = new() + { + ExtractHandler = destinationDirectory => + { + File.WriteAllText(Path.Combine(destinationDirectory, "extracted.gib"), "extracted"); + cancellation.Cancel(); + } + }; + SingleFilePackageUpdater updater = CreateUpdater( + CreateWritingDownloader("archive"), + archiveExtractor); + + Func update = () => updater.UpdateAsync( + CreateMetadata("package.zip", 7), + paths, + null, + cancellation.Token); + + await update.Should().ThrowAsync(); + Directory.Exists(paths.InstalledPath.FullPath).Should().BeFalse(); + File.Exists(Path.Combine(paths.TemporaryPath.FullPath, "package.zip")).Should().BeTrue(); + } + + /// + /// A remote that declares no package size still has to publish the transfer's closing byte count, or the panel + /// is left showing a mid-transfer figure after the download has already finished. + /// + [Fact] + public async Task UpdateAsync_UndeclaredPackageSize_ReportsTheFinalTransferredByteCountAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + RecordingFileDownloader downloader = new() + { + ProgressHandler = async (request, progress, cancellationToken) => + { + await File.WriteAllTextAsync(request.DestinationFilePath, "payload", cancellationToken); + progress?.Report(new DownloadProgress(7, 3, null)); + progress?.Report(new DownloadProgress(7, 7, 100)); + } + }; + SingleFilePackageUpdater updater = CreateUpdater( + downloader, + CreateUnusedArchiveExtractor()); + RecordingProgress progress = new(); + + await updater.UpdateAsync( + CreateMetadata("readme.txt", null), + paths, + progress, + CancellationToken.None); + + progress.Reports[^1].BytesRead.Should().Be(7); + } + + /// + /// A transfer whose own end the downloader cannot state still moves the byte counter; only the closing report + /// depends on knowing where the transfer ends. + /// + [Fact] + public async Task UpdateAsync_TransferSizeUnknown_StillReportsTransferredBytesAsync() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + RecordingFileDownloader downloader = new() + { + ProgressHandler = async (request, progress, cancellationToken) => + { + progress?.Report(new DownloadProgress(null, 3, null)); + await File.WriteAllTextAsync(request.DestinationFilePath, "payload", cancellationToken); + } + }; + SingleFilePackageUpdater updater = CreateUpdater( + downloader, + CreateUnusedArchiveExtractor()); + RecordingProgress progress = new(); + + await updater.UpdateAsync( + CreateMetadata("readme.txt", 7), + paths, + progress, + CancellationToken.None); + + progress.Reports.Should().ContainSingle().Which.BytesRead.Should().Be(3); + } + + private static SingleFilePackageUpdater CreateUpdater( + RecordingFileDownloader downloader, + RecordingArchiveExtractor archiveExtractor) + { + return new SingleFilePackageUpdater( + downloader, + archiveExtractor, + NullLogger.Instance); + } + + private static DownloadFileMetadata CreateMetadata(string fileName, long? totalBytes) + { + return new DownloadFileMetadata( + new Uri("https://example.test/" + fileName), + fileName, + totalBytes); + } + + private static PackageUpdatePathSet CreatePackagePaths(TestDirectory testDirectory) + { + return TestPackageUpdatePaths.Create( + TestLauncherPaths.Create(testDirectory), + _versionRelativePath, + _versionRelativePath); + } + + private static RecordingFileDownloader CreateWritingDownloader(string contents) + { + return new RecordingFileDownloader + { + Handler = (request, cancellationToken) => + File.WriteAllTextAsync(request.DestinationFilePath, contents, cancellationToken) + }; + } + + /// + /// Fails the test if a non-archive download is handed to the extractor. + /// + private static RecordingArchiveExtractor CreateUnusedArchiveExtractor() + { + return new RecordingArchiveExtractor + { + ExtractHandler = _ => throw new InvalidOperationException( + "Extraction should not be used for non-archive files.") + }; + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Support/DownloadLinkResolverTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Support/DownloadLinkResolverTests.cs new file mode 100644 index 00000000..a1d4b85b --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/DownloadLinkResolverTests.cs @@ -0,0 +1,70 @@ +using System; +using GenLauncherGO.Infrastructure.Updating.Support; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Support; + +public sealed class DownloadLinkResolverTests +{ + /// + /// The catalog is an external contract that hands over share links with their query parameters in whatever + /// order the sharing UI produced, so each part is located by name rather than by position. + /// + [Theory] + [InlineData( + "https://www.dropbox.com/s/example/Package.7z?dl=0", + "https://www.dropbox.com/s/example/Package.7z?dl=1")] + [InlineData( + "https://onedrive.live.com/embed?cid=abc&resid=abc%211", + "https://onedrive.live.com/download?cid=abc&resid=abc%211")] + [InlineData( + "https://onedrive.live.com/?authkey=%21key&cid=896C9369E9176506&id=896C9369E9176506%21464&parId=896C9369E9176506%21463&o=OneUp", + "https://onedrive.live.com/download?cid=896C9369E9176506&resid=896C9369E9176506%21464&authkey=%21key")] + [InlineData( + "https://onedrive.live.com/?cid=896C9369E9176506&id=896C9369E9176506%21464&authkey=%21key", + "https://onedrive.live.com/download?cid=896C9369E9176506&resid=896C9369E9176506%21464&authkey=%21key")] + public void ResolveDirectDownloadLink_ConvertsSupportedShareLinks( + string link, + string expected) + { + string resolved = DownloadLinkResolver.ResolveDirectDownloadLink(link); + + resolved.Should().Be(expected); + } + + /// + /// The share links come from a catalog nobody here controls, so a OneDrive address can arrive with its query + /// parameters truncated or renamed. Each part is resolved independently and a missing one leaves its slot + /// empty: the resulting address fails as a download, which the downloader already reports, rather than + /// throwing out of link resolution and taking the whole catalog load down with it. + /// + [Theory] + [InlineData( + "https://onedrive.live.com/?o=OneUp", + "https://onedrive.live.com/download?cid=&resid=&authkey=")] + [InlineData( + "https://onedrive.live.com/?cid=896C9369E9176506&o=OneUp", + "https://onedrive.live.com/download?cid=896C9369E9176506&resid=&authkey=")] + public void ResolveDirectDownloadLink_OneDriveLinkMissingParts_LeavesThoseSlotsEmpty( + string link, + string expected) + { + string resolved = DownloadLinkResolver.ResolveDirectDownloadLink(link); + + resolved.Should().Be(expected); + } + + /// + /// A catalog entry that carries no download link is rejected as bad metadata, instead of being carried into the + /// downloader as an unusable address. + /// + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ResolveDirectDownloadLink_MissingLink_RejectsTheMetadata(string link) + { + Action resolve = () => DownloadLinkResolver.ResolveDirectDownloadLink(link); + + resolve.Should().Throw() + .WithParameterName(nameof(link)); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Support/MonotonicPackageProgressTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Support/MonotonicPackageProgressTests.cs new file mode 100644 index 00000000..4bc683f0 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/MonotonicPackageProgressTests.cs @@ -0,0 +1,181 @@ +using System; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Support; + +/// +/// Concurrent S3 file reporters publish their own view of one package transfer, so the reports arriving at the UI +/// are only usable once they are normalized into a progress value that never regresses. +/// +public sealed class MonotonicPackageProgressTests +{ + [Fact] + public void Report_LateSmallerByteCount_KeepsTheFurthestReportedPosition() + { + RecordingProgress inner = new(); + MonotonicPackageProgress progress = new(inner); + progress.Report(new PackageUpdateProgress(500, 300, null, "first.big")); + + progress.Report(new PackageUpdateProgress(500, 120, null, "second.big")); + + inner.Reports[^1].BytesRead.Should().Be(300); + } + + [Fact] + public void Report_NegativeByteCount_ReportsNoTransferredBytes() + { + RecordingProgress inner = new(); + MonotonicPackageProgress progress = new(inner); + + progress.Report(new PackageUpdateProgress(null, -5, null, null)); + + inner.Reports.Should().ContainSingle().Which.BytesRead.Should().Be(0); + } + + [Fact] + public void Report_ShrinkingPackageSize_KeepsTheLargestKnownPackageSize() + { + RecordingProgress inner = new(); + MonotonicPackageProgress progress = new(inner); + progress.Report(new PackageUpdateProgress(500, 100, null, null)); + + progress.Report(new PackageUpdateProgress(200, 100, null, null)); + + inner.Reports[^1].TotalBytes.Should().Be(500); + } + + /// + /// A reporter that only knows its own byte count must not erase the package size an earlier reporter + /// established, or the bar loses its scale mid-transfer. + /// + [Fact] + public void Report_PackageSizeOmitted_KeepsThePreviouslyReportedPackageSize() + { + RecordingProgress inner = new(); + MonotonicPackageProgress progress = new(inner); + progress.Report(new PackageUpdateProgress(200, 50, null, null)); + + progress.Report(new PackageUpdateProgress(null, 100, null, null)); + + inner.Reports[^1].TotalBytes.Should().Be(200); + } + + [Fact] + public void Report_NegativePackageSize_ReportsNoKnownPackageSize() + { + RecordingProgress inner = new(); + MonotonicPackageProgress progress = new(inner); + + progress.Report(new PackageUpdateProgress(-500, 0, null, null)); + + inner.Reports.Should().ContainSingle().Which.TotalBytes.Should().Be(0); + } + + /// + /// A transfer that overruns its declared size is a stale size, not 400% progress, so the size grows to what has + /// actually arrived. + /// + [Fact] + public void Report_ByteCountBeyondPackageSize_GrowsThePackageSizeToMatch() + { + RecordingProgress inner = new(); + MonotonicPackageProgress progress = new(inner); + + progress.Report(new PackageUpdateProgress(100, 400, null, null)); + + inner.Reports.Should().ContainSingle().Which.TotalBytes.Should().Be(400); + } + + [Fact] + public void Report_PercentageOmitted_DerivesItFromTheNormalizedByteCounts() + { + RecordingProgress inner = new(); + MonotonicPackageProgress progress = new(inner); + + progress.Report(new PackageUpdateProgress(200, 50, null, null)); + + inner.Reports.Should().ContainSingle().Which.ProgressPercentage.Should().Be(25D); + } + + /// + /// An unknown package size cannot be turned into a percentage, and a bar told "0 of 0" would otherwise be asked + /// to render a division by zero. + /// + [Fact] + public void Report_EmptyPackageSize_ReportsNoPercentage() + { + RecordingProgress inner = new(); + MonotonicPackageProgress progress = new(inner); + + progress.Report(new PackageUpdateProgress(0, 0, null, null)); + + inner.Reports.Should().ContainSingle().Which.ProgressPercentage.Should().BeNull(); + } + + /// + /// A provider that publishes its own percentage is the authority on it: an S3 package reports one aggregate + /// percentage across concurrent files, which no single byte ratio reproduces. + /// + [Fact] + public void Report_PercentageProvided_ForwardsItInsteadOfTheByteRatio() + { + RecordingProgress inner = new(); + MonotonicPackageProgress progress = new(inner); + + progress.Report(new PackageUpdateProgress(200, 50, 90D, null)); + + inner.Reports.Should().ContainSingle().Which.ProgressPercentage.Should().Be(90D); + } + + [Fact] + public void Report_LowerPercentage_KeepsTheHighestReportedPercentage() + { + RecordingProgress inner = new(); + MonotonicPackageProgress progress = new(inner); + progress.Report(new PackageUpdateProgress(200, 100, 50D, null)); + + progress.Report(new PackageUpdateProgress(200, 100, 10D, null)); + + inner.Reports[^1].ProgressPercentage.Should().Be(50D); + } + + [Theory] + [InlineData(5000D, 100D)] + [InlineData(-20D, 0D)] + public void Report_PercentageOutsideItsRange_ClampsItToTheBar( + double reportedPercentage, + double expectedPercentage) + { + RecordingProgress inner = new(); + MonotonicPackageProgress progress = new(inner); + + progress.Report(new PackageUpdateProgress(200, 0, reportedPercentage, null)); + + inner.Reports.Should().ContainSingle().Which.ProgressPercentage.Should().Be(expectedPercentage); + } + + /// + /// Only the three regression-prone values are normalized; the rest of the report is what the transfer panel + /// shows and must arrive unchanged. + /// + [Fact] + public void Report_ForwardsTheReportedFileNameSpeedAndEstimate() + { + RecordingProgress inner = new(); + MonotonicPackageProgress progress = new(inner); + + progress.Report(new PackageUpdateProgress( + 200, + 50, + null, + "Data/english.big", + 2048D, + TimeSpan.FromSeconds(3))); + + PackageUpdateProgress report = inner.Reports.Should().ContainSingle().Which; + report.FileName.Should().Be("Data/english.big"); + report.DownloadSpeedBytesPerSecond.Should().Be(2048D); + report.EstimatedTimeRemaining.Should().Be(TimeSpan.FromSeconds(3)); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageInstallFolderReplacerTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageInstallFolderReplacerTests.cs new file mode 100644 index 00000000..606e81e8 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageInstallFolderReplacerTests.cs @@ -0,0 +1,478 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Support; + +public sealed class PackageInstallFolderReplacerTests +{ + private static readonly string _versionRelativePath = Path.Combine("Mod", "1.0"); + + [Fact] + public void Replace_MovesTemporaryFolderIntoNewInstalledLocation() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + File.WriteAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt"), "new"); + + PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance); + + Directory.Exists(paths.TemporaryPath.FullPath).Should().BeFalse(); + File.ReadAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt")).Should().Be("new"); + Directory.Exists(paths.BackupPath.OwnerRoot).Should().BeFalse(); + } + + [Fact] + public void Replace_MovesExistingInstallToBackupBeforeReplacingIt() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + Directory.CreateDirectory(paths.InstalledPath.FullPath); + File.WriteAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt"), "new"); + File.WriteAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt"), "old"); + + PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance); + + File.ReadAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt")).Should().Be("new"); + Directory.Exists(paths.BackupPath.FullPath).Should().BeFalse(); + Directory.Exists(Path.GetDirectoryName(paths.BackupPath.FullPath)!).Should().BeFalse(); + Directory.Exists(paths.BackupPath.OwnerRoot).Should().BeFalse(); + } + + [Fact] + public void Replace_ThrowsWhenTemporaryFolderDoesNotExist() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + + Action act = () => PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance); + + act.Should().Throw() + .WithMessage("*Temporary package folder*"); + } + + [Fact] + public void Replace_ThrowsWhenTemporaryTreeContainsReparsePoint() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + string linkTarget = testDirectory.CreateDirectory("linked-target"); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + File.WriteAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt"), "new"); + ReparsePointTestSupport.CreateDirectoryJunction( + Path.Combine(paths.TemporaryPath.FullPath, "Linked"), + linkTarget); + + Action act = () => PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance); + + act.Should().Throw() + .WithMessage("*reparse point*"); + Directory.Exists(paths.TemporaryPath.FullPath).Should().BeTrue(); + Directory.Exists(paths.InstalledPath.FullPath).Should().BeFalse(); + } + + [Fact] + public void Replace_ThrowsWhenInstalledPathChainContainsReparsePoint() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + string realInstalledRoot = testDirectory.CreateDirectory("real-installed"); + string linkedInstalledRoot = Path.Combine(testDirectory.Path, "installed-link"); + var linkedInstalledPath = new OwnedContentPath( + testDirectory.Path, + Path.Combine(linkedInstalledRoot, _versionRelativePath)); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + File.WriteAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt"), "new"); + ReparsePointTestSupport.CreateDirectoryJunction(linkedInstalledRoot, realInstalledRoot); + + Action act = () => PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + linkedInstalledPath, + paths.BackupPath, + NullLogger.Instance); + + act.Should().Throw() + .WithMessage("*reparse point*"); + Directory.Exists(paths.TemporaryPath.FullPath).Should().BeTrue(); + Directory.EnumerateFileSystemEntries(realInstalledRoot).Should().BeEmpty(); + } + + [Fact] + public void Replace_RestoresExistingInstallWhenReplacementMoveFails() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + Directory.CreateDirectory(paths.InstalledPath.FullPath); + File.WriteAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt"), "old"); + + Action act = () => PackageInstallFolderReplacer.Replace( + paths.InstalledPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance); + + act.Should().Throw(); + File.ReadAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt")).Should().Be("old"); + Directory.Exists(paths.BackupPath.OwnerRoot).Should().BeFalse(); + } + + [Fact] + public void Replace_LeavesLegitimateSiblingVersionNamedBackupUntouched() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + string legitimateSibling = paths.InstalledPath.FullPath + ".backup"; + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + Directory.CreateDirectory(paths.InstalledPath.FullPath); + Directory.CreateDirectory(legitimateSibling); + File.WriteAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt"), "new"); + File.WriteAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt"), "old"); + File.WriteAllText(Path.Combine(legitimateSibling, "asset.txt"), "legitimate"); + + PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance); + + File.ReadAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt")).Should().Be("new"); + File.ReadAllText(Path.Combine(legitimateSibling, "asset.txt")).Should().Be("legitimate"); + Directory.Exists(paths.BackupPath.FullPath).Should().BeFalse(); + } + + /// + /// A recovery backup that overlaps installed or staged content in either direction would delete the very tree it + /// exists to restore, so the overlap is rejected before anything on disk is touched. + /// + [Theory] + [InlineData("BackupInsideInstalled")] + [InlineData("InstalledInsideBackup")] + [InlineData("BackupInsideTemporary")] + [InlineData("TemporaryInsideBackup")] + public void Replace_OverlappingRecoveryPath_RejectsBeforeMutation(string overlap) + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + Directory.CreateDirectory(paths.InstalledPath.FullPath); + File.WriteAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt"), "new"); + File.WriteAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt"), "old"); + string overlappingBackup = overlap switch + { + "BackupInsideInstalled" => Path.Combine(paths.InstalledPath.FullPath, "recovery"), + "InstalledInsideBackup" => Path.GetDirectoryName(paths.InstalledPath.FullPath)!, + "BackupInsideTemporary" => Path.Combine(paths.TemporaryPath.FullPath, "recovery"), + "TemporaryInsideBackup" => Path.GetDirectoryName(paths.TemporaryPath.FullPath)!, + _ => throw new ArgumentOutOfRangeException(nameof(overlap), overlap, null) + }; + + Action act = () => PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + new OwnedContentPath(testDirectory.Path, overlappingBackup), + NullLogger.Instance); + + act.Should().Throw() + .WithParameterName("backupPath"); + File.ReadAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt")).Should().Be("old"); + File.ReadAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt")).Should().Be("new"); + } + + [Fact] + public void Replace_KeepsCommittedInstallAndDurableRecoveryBackupWhenPostCommitCleanupFails() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + Directory.CreateDirectory(paths.InstalledPath.FullPath); + File.WriteAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt"), "new"); + File.WriteAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt"), "old"); + + PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance, + _ => throw new IOException("cleanup failed")); + + File.ReadAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt")).Should().Be("new"); + File.ReadAllText(Path.Combine(paths.BackupPath.FullPath, "asset.txt")).Should().Be("old"); + } + + /// + /// A recovery backup that outlives its cleanup is the only copy of the previous install, so a replacement that + /// cannot remove it must stop rather than overwrite the install it can no longer restore. + /// + [Fact] + public void Replace_StaleRecoveryBackupSurvivesCleanup_FailsBeforeMutation() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + Directory.CreateDirectory(paths.InstalledPath.FullPath); + Directory.CreateDirectory(paths.BackupPath.FullPath); + File.WriteAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt"), "next"); + File.WriteAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt"), "current"); + File.WriteAllText(Path.Combine(paths.BackupPath.FullPath, "asset.txt"), "old"); + + Action act = () => PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance, + _ => { }); + + act.Should().Throw() + .WithMessage("*stale recovery backup*"); + File.ReadAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt")).Should().Be("current"); + File.ReadAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt")).Should().Be("next"); + } + + [Fact] + public void Replace_RestoresInterruptedBackupBeforeRejectingMissingStagingFolder() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + Directory.CreateDirectory(paths.BackupPath.FullPath); + File.WriteAllText(Path.Combine(paths.BackupPath.FullPath, "asset.txt"), "old"); + + Action act = () => PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance); + + act.Should().Throw(); + File.ReadAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt")).Should().Be("old"); + Directory.Exists(paths.BackupPath.OwnerRoot).Should().BeFalse(); + } + + [Fact] + public void Replace_CommittedBackup_ReconcilesBeforeNextReplacement() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + Directory.CreateDirectory(paths.InstalledPath.FullPath); + Directory.CreateDirectory(paths.BackupPath.FullPath); + File.WriteAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt"), "next"); + File.WriteAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt"), "current"); + File.WriteAllText(Path.Combine(paths.BackupPath.FullPath, "asset.txt"), "old"); + + PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance); + + File.ReadAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt")).Should().Be("next"); + Directory.Exists(paths.BackupPath.FullPath).Should().BeFalse(); + } + + [Fact] + public void Replace_RejectsLinkedRecoveryBackup() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + string linkTarget = testDirectory.CreateDirectory("outside-backup"); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + Directory.CreateDirectory(paths.InstalledPath.FullPath); + File.WriteAllText(Path.Combine(linkTarget, "asset.txt"), "outside"); + Directory.CreateDirectory(Path.GetDirectoryName(paths.BackupPath.FullPath)!); + ReparsePointTestSupport.CreateDirectoryJunction(paths.BackupPath.FullPath, linkTarget); + + Action act = () => PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance); + + act.Should().Throw() + .WithMessage("*reparse point*"); + File.ReadAllText(Path.Combine(linkTarget, "asset.txt")).Should().Be("outside"); + Directory.Exists(paths.TemporaryPath.FullPath).Should().BeTrue(); + } + + /// + /// A durable recovery backup is the only copy of the previous install, so a replacement that discovers a linked + /// install folder must stop before the reconciliation step that would discard it. + /// + [Fact] + public void Replace_LinkedInstalledFolder_RejectsWithoutDiscardingTheRecoveryBackup() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + string outsideInstall = testDirectory.CreateDirectory("outside-install"); + string outsideAssetPath = testDirectory.CreateFile( + Path.Combine("outside-install", "outside.txt"), + "outside"); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + Directory.CreateDirectory(paths.BackupPath.FullPath); + Directory.CreateDirectory(Path.GetDirectoryName(paths.InstalledPath.FullPath)!); + File.WriteAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt"), "next"); + File.WriteAllText(Path.Combine(paths.BackupPath.FullPath, "asset.txt"), "old"); + ReparsePointTestSupport.CreateDirectoryJunction(paths.InstalledPath.FullPath, outsideInstall); + + Action act = () => PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance); + + act.Should().Throw() + .WithMessage("*reparse point*"); + File.ReadAllText(Path.Combine(paths.BackupPath.FullPath, "asset.txt")).Should().Be("old"); + File.ReadAllText(outsideAssetPath).Should().Be("outside"); + } + + /// + /// The install folder is validated again inside the staged move, because the recovery-state cleanup that runs + /// first gives another process a window to swap the folder for a link the earlier check already cleared. + /// + [Fact] + public void Replace_InstalledFolderLinkedDuringRecoveryCleanup_RejectsBeforeMovingAnything() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + string outsideInstall = testDirectory.CreateDirectory("outside-install"); + string outsideAssetPath = testDirectory.CreateFile( + Path.Combine("outside-install", "outside.txt"), + "outside"); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + Directory.CreateDirectory(paths.InstalledPath.FullPath); + Directory.CreateDirectory(paths.BackupPath.FullPath); + File.WriteAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt"), "next"); + File.WriteAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt"), "current"); + File.WriteAllText(Path.Combine(paths.BackupPath.FullPath, "asset.txt"), "old"); + + Action act = () => PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance, + ownedBackupPath => + { + Directory.Delete(ownedBackupPath.FullPath, true); + Directory.Delete(paths.InstalledPath.FullPath, true); + ReparsePointTestSupport.CreateDirectoryJunction( + paths.InstalledPath.FullPath, + outsideInstall); + }); + + act.Should().Throw() + .WithMessage("*reparse point*"); + File.ReadAllText(outsideAssetPath).Should().Be("outside"); + File.ReadAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt")).Should().Be("next"); + } + + /// + /// Staging reached only by following a linked parent belongs to whatever the link points at, so the replacement + /// must refuse it instead of moving that content into the launcher's installed package location. + /// + [Fact] + public void Replace_TemporaryFolderBehindLinkedParent_RejectsWithoutConsumingTheLinkTarget() + { + using TestDirectory testDirectory = new(); + LauncherPaths launcherPaths = TestLauncherPaths.Create(testDirectory); + PackageUpdatePathSet paths = TestPackageUpdatePaths.Create( + launcherPaths, + _versionRelativePath, + _versionRelativePath); + string outsideStaging = testDirectory.CreateDirectory("outside-staging"); + string outsideAssetPath = testDirectory.CreateFile( + Path.Combine("outside-staging", "1.0", "asset.txt"), + "outside"); + Directory.CreateDirectory(launcherPaths.PackagesDirectory); + ReparsePointTestSupport.CreateDirectoryJunction( + Path.GetDirectoryName(paths.TemporaryPath.FullPath)!, + outsideStaging); + + Action act = () => PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance); + + act.Should().Throw() + .WithMessage("*reparse point*"); + File.ReadAllText(outsideAssetPath).Should().Be("outside"); + Directory.Exists(paths.InstalledPath.FullPath).Should().BeFalse(); + } + + /// + /// A first install has no previous version to fall back to, so it creates no recovery backup and must leave the + /// recovery root that other packages share exactly as it found it. + /// + [Fact] + public void Replace_FirstInstall_LeavesTheSharedRecoveryRootUntouched() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + Directory.CreateDirectory(paths.TemporaryPath.FullPath); + Directory.CreateDirectory(paths.BackupPath.OwnerRoot); + File.WriteAllText(Path.Combine(paths.TemporaryPath.FullPath, "asset.txt"), "new"); + + PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance); + + File.ReadAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt")).Should().Be("new"); + Directory.Exists(paths.BackupPath.OwnerRoot).Should().BeTrue(); + } + + /// + /// Removing a committed package's stale recovery backup also prunes the recovery folders it emptied, so the + /// launcher's state directory does not accumulate one dead folder per package it has ever updated. + /// + [Fact] + public void Replace_StaleRecoveryBackupRemoved_PrunesTheEmptiedRecoveryFolders() + { + using TestDirectory testDirectory = new(); + PackageUpdatePathSet paths = CreatePackagePaths(testDirectory); + Directory.CreateDirectory(paths.InstalledPath.FullPath); + Directory.CreateDirectory(paths.BackupPath.FullPath); + File.WriteAllText(Path.Combine(paths.InstalledPath.FullPath, "asset.txt"), "current"); + File.WriteAllText(Path.Combine(paths.BackupPath.FullPath, "asset.txt"), "old"); + + Action act = () => PackageInstallFolderReplacer.Replace( + paths.TemporaryPath, + paths.InstalledPath, + paths.BackupPath, + NullLogger.Instance); + + act.Should().Throw(); + Directory.Exists(paths.BackupPath.OwnerRoot).Should().BeFalse(); + } + + private static PackageUpdatePathSet CreatePackagePaths(TestDirectory testDirectory) + { + return TestPackageUpdatePaths.Create( + TestLauncherPaths.Create(testDirectory), + _versionRelativePath, + _versionRelativePath); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageProgressTrackerTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageProgressTrackerTests.cs new file mode 100644 index 00000000..84a504ff --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageProgressTrackerTests.cs @@ -0,0 +1,275 @@ +using System; +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() + { + PackageProgressTracker tracker = new(200); + + PackageUpdateProgress? progress = tracker.Update("file-a", 50); + + progress.Should().NotBeNull(); + progress!.TotalBytes.Should().Be(200); + progress.BytesRead.Should().Be(50); + progress.ProgressPercentage.Should().Be(25); + } + + [Fact] + public void Update_DoesNotRegressWhenAnItemReportsFewerBytes() + { + PackageProgressTracker tracker = new(100); + + tracker.Update("file-a", 80); + PackageUpdateProgress? progress = tracker.Update("file-a", -5, true); + + progress.Should().NotBeNull(); + progress!.BytesRead.Should().Be(80); + progress.ProgressPercentage.Should().Be(80); + } + + [Fact] + public void Update_ClampsProgressAtOneHundredPercent() + { + PackageProgressTracker tracker = new(100); + + PackageUpdateProgress? progress = tracker.Update("file-a", 150, true); + + progress.Should().NotBeNull(); + progress!.BytesRead.Should().Be(150); + progress.ProgressPercentage.Should().Be(100); + } + + /// + /// Throttling must never swallow the terminal report: the transfer reaching the package total is published even + /// though the caller did not force it and the report interval has not elapsed. + /// + [Fact] + public void Update_ThrottlesRepeatedReportsUntilComplete() + { + ManualTimeProvider timeProvider = new(); + PackageProgressTracker tracker = new(100, timeProvider: timeProvider); + + PackageUpdateProgress? firstProgress = tracker.Update("file-a", 10); + PackageUpdateProgress? throttledProgress = tracker.Update("file-a", 20); + PackageUpdateProgress? completedProgress = tracker.Update("file-a", 100); + + firstProgress.Should().NotBeNull(); + throttledProgress.Should().BeNull(); + completedProgress.Should().NotBeNull(); + completedProgress!.ProgressPercentage.Should().Be(100); + } + + [Fact] + public void AddExpectedBytes_IncreasesKnownTotal() + { + PackageProgressTracker tracker = new(100); + + tracker.AddExpectedBytes(50); + PackageUpdateProgress? progress = tracker.Update("file-a", 75); + + progress.Should().NotBeNull(); + progress!.TotalBytes.Should().Be(150); + progress.ProgressPercentage.Should().Be(50); + } + + [Fact] + public void AddExpectedBytes_IgnoresNonPositiveValues() + { + PackageProgressTracker tracker = new(100); + + tracker.AddExpectedBytes(0); + tracker.AddExpectedBytes(-1); + PackageUpdateProgress? progress = tracker.Update("file-a", 50); + + progress.Should().NotBeNull(); + progress!.TotalBytes.Should().Be(100); + } + + [Fact] + public void Update_ReportsSpeedAndEtaAfterEnoughElapsedTime() + { + ManualTimeProvider timeProvider = new(); + PackageProgressTracker tracker = new(200, timeProvider: timeProvider); + + timeProvider.Advance(TimeSpan.FromMilliseconds(300)); + PackageUpdateProgress? progress = tracker.Update("file-a", 50, true); + + progress.Should().NotBeNull(); + progress!.DownloadSpeedBytesPerSecond.Should().BeGreaterThan(0); + progress.EstimatedTimeRemaining.Should().NotBeNull(); + } + + /// + /// A resumed transfer reports against the whole package, so the bar continues from where it stopped and the + /// total stays the package's real size rather than shrinking to whatever is left. + /// + [Fact] + public void ResumedBytes_CountTowardsProgressImmediately() + { + PackageProgressTracker tracker = new(1000, 400); + + PackageUpdateProgress? progress = tracker.Update("file-a", 100, true); + + progress.Should().NotBeNull(); + progress!.TotalBytes.Should().Be(1000); + progress.BytesRead.Should().Be(500); + progress.ProgressPercentage.Should().Be(50); + } + + /// + /// Bytes that were already on disk were not moved by this session, so crediting them to the transfer rate + /// would report a speed and an estimate the connection is not actually achieving. + /// + [Fact] + public void ReportedTransferRate_ResumedBytes_ExcludesPreviouslyDownloadedBytes() + { + ManualTimeProvider timeProvider = new(); + PackageProgressTracker tracker = new(1000, 400, timeProvider); + + timeProvider.Advance(TimeSpan.FromSeconds(2)); + PackageUpdateProgress? progress = tracker.Update("file-a", 100, true); + + progress.Should().NotBeNull(); + progress!.DownloadSpeedBytesPerSecond.Should().Be(50); + } + + /// + /// Throttling is measured from the previous published report, so the first update after the interval has passed + /// reaches the UI instead of waiting for the one after it. + /// + [Fact] + public void Update_OnTheReportInterval_PublishesTheReport() + { + ManualTimeProvider timeProvider = new(); + PackageProgressTracker tracker = new(1000, timeProvider: timeProvider); + tracker.Update("file-a", 100); + + timeProvider.Advance(TimeSpan.FromMilliseconds(100)); + PackageUpdateProgress? progress = tracker.Update("file-a", 200); + + progress.Should().NotBeNull(); + } + + /// + /// A transfer that has been running for a while still throttles: the interval is measured from the last report, + /// not from the start, so a chatty downloader cannot flood the UI later in the transfer. + /// + [Fact] + public void Update_WithinTheReportIntervalOfTheLastReport_PublishesNothing() + { + ManualTimeProvider timeProvider = new(); + PackageProgressTracker tracker = new(1000, timeProvider: timeProvider); + timeProvider.Advance(TimeSpan.FromMilliseconds(200)); + tracker.Update("file-a", 100); + + timeProvider.Advance(TimeSpan.FromMilliseconds(50)); + PackageUpdateProgress? progress = tracker.Update("file-a", 150); + + progress.Should().BeNull(); + } + + /// + /// An empty manifest gives a package size of zero, which has no percentage to show; dividing by it would put a + /// on the progress bar. + /// + [Fact] + public void Update_EmptyPackage_ReportsNoPercentage() + { + PackageProgressTracker tracker = new(0); + + PackageUpdateProgress? progress = tracker.Update("file-a", 0, true); + + progress.Should().NotBeNull(); + progress!.ProgressPercentage.Should().BeNull(); + } + + /// + /// A failed hash costs a retry that enlarges the expected total, so the same transferred bytes are suddenly a + /// smaller share of the package. The bar holds its position rather than jumping backwards. + /// + [Fact] + public void Update_ExpectedTotalGrowsAfterAReport_HoldsTheReportedPercentage() + { + PackageProgressTracker tracker = new(100); + tracker.Update("file-a", 80, true); + tracker.AddExpectedBytes(100); + + PackageUpdateProgress? progress = tracker.Update("file-a", 80, true); + + progress.Should().NotBeNull(); + progress!.ProgressPercentage.Should().Be(80); + } + + /// + /// A rate measured over a quarter second or less is noise rather than a transfer speed, so none is reported + /// until the sampling window has actually passed. + /// + [Fact] + public void Update_OnTheRateSamplingWindow_ReportsNoTransferRate() + { + ManualTimeProvider timeProvider = new(); + PackageProgressTracker tracker = new(1000, timeProvider: timeProvider); + + timeProvider.Advance(TimeSpan.FromMilliseconds(250)); + PackageUpdateProgress? progress = tracker.Update("file-a", 100, true); + + progress.Should().NotBeNull(); + progress!.DownloadSpeedBytesPerSecond.Should().BeNull(); + } + + /// + /// A resumed transfer that has not yet received a new byte has no measured rate at all, which is not the same + /// claim as a measured rate of zero. + /// + [Fact] + public void Update_NoBytesTransferredSinceResuming_ReportsNoTransferRate() + { + ManualTimeProvider timeProvider = new(); + PackageProgressTracker tracker = new(1000, 400, timeProvider); + + timeProvider.Advance(TimeSpan.FromSeconds(1)); + PackageUpdateProgress? progress = tracker.Update("file-a", 0, true); + + progress.Should().NotBeNull(); + progress!.DownloadSpeedBytesPerSecond.Should().BeNull(); + } + + /// + /// A package whose size the provider never declared still has a measurable transfer rate, but nothing to + /// subtract it from, so no time estimate can be offered. + /// + [Fact] + public void Update_UnknownPackageSize_ReportsATransferRateButNoEstimate() + { + ManualTimeProvider timeProvider = new(); + PackageProgressTracker tracker = new(null, timeProvider: timeProvider); + + timeProvider.Advance(TimeSpan.FromSeconds(2)); + PackageUpdateProgress? progress = tracker.Update("file-a", 500, true); + + progress.Should().NotBeNull(); + progress!.DownloadSpeedBytesPerSecond.Should().Be(250); + progress.EstimatedTimeRemaining.Should().BeNull(); + } + + /// + /// The estimate the panel counts down is the bytes still outstanding divided by the rate measured so far. + /// + [Fact] + public void Update_EstimatesTheTimeRemainingFromTheOutstandingBytesAndMeasuredRate() + { + ManualTimeProvider timeProvider = new(); + PackageProgressTracker tracker = new(1000, timeProvider: timeProvider); + + timeProvider.Advance(TimeSpan.FromSeconds(2)); + PackageUpdateProgress? progress = tracker.Update("file-a", 500, true); + + progress.Should().NotBeNull(); + progress!.EstimatedTimeRemaining.Should().Be(TimeSpan.FromSeconds(2)); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageStagingFolderCleanerTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageStagingFolderCleanerTests.cs new file mode 100644 index 00000000..1648f7a4 --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/PackageStagingFolderCleanerTests.cs @@ -0,0 +1,196 @@ +using System; +using System.IO; +using System.Linq; +using System.Threading; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Support; + +public sealed class PackageStagingFolderCleanerTests +{ + [Fact] + public void ClearDirectory_CreatesStagingFolderAndDeletesExistingChildren() + { + 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"); + + PackageStagingFolderCleaner.ClearDirectory( + OwnPackagePath(testDirectory.Path, stagingFolder), + NullLogger.Instance); + + Directory.Exists(stagingFolder).Should().BeTrue(); + Directory.EnumerateFileSystemEntries(stagingFolder).Should().BeEmpty(); + } + + [Fact] + public void DeleteEmptyPackageParents_RemovesEmptyChainThroughPackagesFolder() + { + 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); + + PackageStagingFolderCleaner.DeleteEmptyPackageParents( + OwnPackagePath(testDirectory.Path, stagingFolder), + NullLogger.Instance); + + Directory.Exists(packageFolder).Should().BeFalse(); + Directory.Exists(packagesFolder).Should().BeFalse(); + Directory.Exists(testDirectory.Path).Should().BeTrue(); + } + + [Fact] + public void DeleteEmptyPackageParents_StopsWhenParentContainsOtherEntries() + { + 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"); + + PackageStagingFolderCleaner.DeleteEmptyPackageParents( + OwnPackagePath(testDirectory.Path, stagingFolder), + NullLogger.Instance); + + Directory.Exists(packageFolder).Should().BeFalse(); + Directory.Exists(packagesFolder).Should().BeTrue(); + File.Exists(Path.Combine(packagesFolder, "keep.txt")).Should().BeTrue(); + } + + [Fact] + public void DeleteEmptyPackageParents_ReturnsWhenPathIsNotUnderPackagesFolder() + { + using TestDirectory testDirectory = new(); + string stagingFolder = Path.Combine(testDirectory.Path, "Mod", "1.0"); + string packageFolder = Path.GetDirectoryName(stagingFolder)!; + Directory.CreateDirectory(packageFolder); + + PackageStagingFolderCleaner.DeleteEmptyPackageParents( + new OwnedContentPath(testDirectory.Path, stagingFolder), + NullLogger.Instance); + + Directory.Exists(packageFolder).Should().BeTrue(); + } + + [Fact] + public void DeleteEmptyPackageParents_ReturnsWhenPackagesAncestorDoesNotExist() + { + using TestDirectory testDirectory = new(); + string stagingFolder = Path.Combine(testDirectory.Path, "Packages", "Mod", "1.0"); + + Action act = () => PackageStagingFolderCleaner.DeleteEmptyPackageParents( + OwnPackagePath(testDirectory.Path, stagingFolder), + NullLogger.Instance); + + act.Should().NotThrow(); + Directory.Exists(Path.Combine(testDirectory.Path, "Packages")).Should().BeFalse(); + } + + [Fact] + public void RemoveUnsafeLinks_HonorsPreCanceledToken() + { + 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(); + + Action act = () => PackageStagingFolderCleaner.RemoveUnsafeLinks( + OwnPackagePath(testDirectory.Path, stagingFolder), + NullLogger.Instance, + cancellationTokenSource.Token); + + act.Should().Throw(); + } + + [Fact] + public void RemoveUnsafeLinks_RecursesThroughOrdinaryDirectories() + { + 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"); + + PackageStagingFolderCleaner.RemoveUnsafeLinks( + OwnPackagePath(testDirectory.Path, stagingFolder), + NullLogger.Instance, + CancellationToken.None); + + File.Exists(filePath).Should().BeTrue(); + } + + [Fact] + public void PruneToManifest_DeletesStaleFilesAndKeepsConvertedBigFiles() + { + 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(Path.Combine(emptyFolder, "Inner", "Deepest")); + 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) + ]; + + PackageStagingFolderCleaner.PruneToManifest( + OwnPackagePath(testDirectory.Path, stagingFolder), + files, + NullLogger.Instance, + CancellationToken.None); + + 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"); + } + + [Fact] + public void PruneToManifest_HonorsPreCanceledToken() + { + using TestDirectory testDirectory = new(); + string stagingFolder = Path.Combine(testDirectory.Path, "Packages", "Mod", "1.0"); + Directory.CreateDirectory(stagingFolder); + File.WriteAllText(Path.Combine(stagingFolder, "keep.txt"), "keep"); + File.WriteAllText(Path.Combine(stagingFolder, "stale.txt"), "stale"); + using CancellationTokenSource cancellationTokenSource = new(); + cancellationTokenSource.Cancel(); + + Action act = () => PackageStagingFolderCleaner.PruneToManifest( + OwnPackagePath(testDirectory.Path, stagingFolder), + [new RemoteFileManifestEntry("keep.txt", "hash", 4)], + NullLogger.Instance, + cancellationTokenSource.Token); + + act.Should().Throw(); + File.Exists(Path.Combine(stagingFolder, "keep.txt")).Should().BeTrue(); + File.Exists(Path.Combine(stagingFolder, "stale.txt")).Should().BeTrue(); + } + + private static OwnedContentPath OwnPackagePath(string root, string fullPath) + { + return new OwnedContentPath(Path.Combine(root, "Packages"), fullPath); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Support/S3HashValidationPolicyTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Support/S3HashValidationPolicyTests.cs new file mode 100644 index 00000000..41b8ef5c --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/S3HashValidationPolicyTests.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Infrastructure.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 IsReliableMd5Hash_ReturnsTrueForPlainHexMd5(string hash) + { + bool result = S3HashValidationPolicy.IsReliableMd5Hash(hash); + + result.Should().BeTrue(); + } + + [Theory] + [InlineData("")] + [InlineData("0123456789abcdef0123456789abcde")] + [InlineData("0123456789abcdef0123456789abcdef-2")] + [InlineData("0123456789abcdef0123456789abcdeg")] + public void IsReliableMd5Hash_ReturnsFalseForMultipartOrMalformedHashes(string hash) + { + bool result = S3HashValidationPolicy.IsReliableMd5Hash(hash); + + result.Should().BeFalse(); + } + + [Theory] + [InlineData("Data/asset.big", "0123456789abcdef0123456789abcdef", true)] + [InlineData("Data/readme.txt", "0123456789abcdef0123456789abcdef", false)] + [InlineData("Data/asset.big", "0123456789abcdef0123456789abcdef-2", false)] + public void ShouldCheckHash_ReturnsExpectedResult( + string path, + string hash, + bool expected) + { + RemoteFileManifestEntry file = new(path, hash, 10); + HashSet hashCheckedExtensions = new( + new[] { ".big" }, + StringComparer.OrdinalIgnoreCase); + + bool result = S3HashValidationPolicy.ShouldCheckHash(file, hashCheckedExtensions); + + result.Should().Be(expected); + } +} diff --git a/GenLauncherGO.Tests/Infrastructure/Updating/Support/S3ReusablePackageFileCopierTests.cs b/GenLauncherGO.Tests/Infrastructure/Updating/Support/S3ReusablePackageFileCopierTests.cs new file mode 100644 index 00000000..7aa582fd --- /dev/null +++ b/GenLauncherGO.Tests/Infrastructure/Updating/Support/S3ReusablePackageFileCopierTests.cs @@ -0,0 +1,374 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Support; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Infrastructure.Updating.Support; + +public sealed class S3ReusablePackageFileCopierTests +{ + [Theory] + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + [InlineData(false, false)] + public async Task CopyUnchangedFilesAsync_RejectsLinkedSourceOrDestinationTreeAsync( + bool sourceIsUnsafe, + bool linkedOwner) + { + using TestDirectory testDirectory = new(); + string sourceOwner = testDirectory.CreateDirectory("SourceOwner"); + string sourcePath = testDirectory.CreateDirectory("SourceOwner/Latest"); + string destinationOwner = testDirectory.CreateDirectory("DestinationOwner"); + string destinationPath = testDirectory.CreateDirectory("DestinationOwner/Staging"); + string unsafeOwner = sourceIsUnsafe ? sourceOwner : destinationOwner; + string unsafePath = sourceIsUnsafe ? sourcePath : destinationPath; + + if (linkedOwner) + { + Directory.Delete(unsafeOwner, true); + string ownerTarget = testDirectory.CreateDirectory("LinkedOwnerTarget"); + Directory.CreateDirectory(Path.Combine(ownerTarget, Path.GetFileName(unsafePath))); + ReparsePointTestSupport.CreateDirectoryJunction(unsafeOwner, ownerTarget); + } + else + { + string linkTarget = testDirectory.CreateDirectory("LinkedChildTarget"); + ReparsePointTestSupport.CreateDirectoryJunction(Path.Combine(unsafePath, "Linked"), linkTarget); + } + + S3ReusablePackageFileCopier copier = CreateReusableFileCopier(new StubFileHashService()); + string expectedMessage = sourceIsUnsafe + ? "Reusable package paths must not contain reparse points." + : "Package staging paths must not contain reparse points."; + + Func copy = () => copier.CopyUnchangedFilesAsync( + new OwnedContentPath(sourceOwner, sourcePath), + new OwnedContentPath(destinationOwner, destinationPath), + Array.Empty(), + CancellationToken.None); + + await copy.Should().ThrowAsync() + .WithMessage(expectedMessage); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CopyUnchangedFilesAsync_CancellationBeforeFileOrDirectoryWork_StopsAsync( + bool sourceContainsFile) + { + using TestDirectory testDirectory = new(); + string sourceOwner = testDirectory.CreateDirectory("SourceOwner"); + string sourcePath = testDirectory.CreateDirectory("SourceOwner/Latest"); + string destinationOwner = testDirectory.CreateDirectory("DestinationOwner"); + string destinationPath = testDirectory.CreateDirectory("DestinationOwner/Staging"); + if (sourceContainsFile) + { + testDirectory.CreateFile("SourceOwner/Latest/unmatched.txt", "unmatched"); + } + else + { + testDirectory.CreateDirectory("SourceOwner/Latest/Nested"); + } + + using CancellationTokenSource cancellationSource = new(); + await cancellationSource.CancelAsync(); + S3ReusablePackageFileCopier copier = CreateReusableFileCopier(new StubFileHashService()); + + Func copy = () => copier.CopyUnchangedFilesAsync( + new OwnedContentPath(sourceOwner, sourcePath), + new OwnedContentPath(destinationOwner, destinationPath), + Array.Empty(), + cancellationSource.Token); + + await copy.Should().ThrowAsync(); + } + + [Theory] + [InlineData(false, "Reusable package paths must not contain reparse points.")] + [InlineData(true, "Package staging paths must not contain reparse points.")] + public async Task CopyUnchangedFilesAsync_RejectsLinkIntroducedAfterHashBeforeCopyOrTraversalAsync( + bool linkDestination, + string expectedMessage) + { + using TestDirectory testDirectory = new(); + string sourceOwner = testDirectory.CreateDirectory("SourceOwner"); + string sourcePath = testDirectory.CreateDirectory("SourceOwner/Latest"); + string sourceNestedPath = testDirectory.CreateDirectory("SourceOwner/Latest/Nested"); + string triggerPath = testDirectory.CreateFile("SourceOwner/Latest/trigger.txt", "payload"); + string destinationOwner = testDirectory.CreateDirectory("DestinationOwner"); + string destinationPath = testDirectory.CreateDirectory("DestinationOwner/Staging"); + string outsidePath = testDirectory.CreateDirectory("Outside"); + StubFileHashService hashService = new() + { + HashForPath = path => + { + if (string.Equals(path, triggerPath, StringComparison.OrdinalIgnoreCase)) + { + string linkPath = sourceNestedPath; + if (linkDestination) + { + Directory.Delete(destinationPath, false); + linkPath = destinationPath; + } + else + { + Directory.Delete(sourceNestedPath, false); + } + + ReparsePointTestSupport.CreateDirectoryJunction(linkPath, outsidePath); + } + + return StubFileHashService.MatchingHash; + } + }; + S3ReusablePackageFileCopier copier = CreateReusableFileCopier(hashService); + + Func copy = () => copier.CopyUnchangedFilesAsync( + new OwnedContentPath(sourceOwner, sourcePath), + new OwnedContentPath(destinationOwner, destinationPath), + [new RemoteFileManifestEntry("trigger.txt", StubFileHashService.MatchingHash, 7)], + CancellationToken.None); + + await copy.Should().ThrowAsync() + .WithMessage(expectedMessage); + Directory.EnumerateFileSystemEntries(outsidePath).Should().BeEmpty(); + } + + [SymbolicLinkFact] + public async Task CopyUnchangedFilesAsync_FileReplacementDuringHash_FailsClosedAsync() + { + using TestDirectory testDirectory = new(); + string sourceOwner = testDirectory.CreateDirectory("SourceOwner"); + string sourcePath = testDirectory.CreateDirectory("SourceOwner/Latest"); + string sourceFilePath = testDirectory.CreateFile("SourceOwner/Latest/payload.txt", "payload"); + string destinationOwner = testDirectory.CreateDirectory("DestinationOwner"); + string destinationPath = testDirectory.CreateDirectory("DestinationOwner/Staging"); + string outsideFilePath = testDirectory.CreateFile("Outside/payload.txt", "outside"); + StubFileHashService hashService = new() + { + HashForPath = path => + { + File.Delete(path); + SymbolicLinkTestSupport.CreateFileLink(path, outsideFilePath); + return StubFileHashService.MatchingHash; + } + }; + S3ReusablePackageFileCopier copier = CreateReusableFileCopier(hashService); + + Func copy = () => copier.CopyUnchangedFilesAsync( + new OwnedContentPath(sourceOwner, sourcePath), + new OwnedContentPath(destinationOwner, destinationPath), + [new RemoteFileManifestEntry("payload.txt", StubFileHashService.MatchingHash, 7)], + CancellationToken.None); + + await copy.Should().ThrowAsync(); + (await File.ReadAllTextAsync(sourceFilePath)).Should().Be("payload"); + File.Exists(Path.Combine(destinationPath, "payload.txt")).Should().BeFalse(); + (await File.ReadAllTextAsync(outsideFilePath)).Should().Be("outside"); + } + + [Fact] + public async Task CopyUnchangedFilesAsync_SameLengthRewriteDuringHash_FailsClosedAsync() + { + using TestDirectory testDirectory = new(); + string sourceOwner = testDirectory.CreateDirectory("SourceOwner"); + string sourcePath = testDirectory.CreateDirectory("SourceOwner/Latest"); + string sourceFilePath = testDirectory.CreateFile("SourceOwner/Latest/payload.txt", "payload"); + string destinationOwner = testDirectory.CreateDirectory("DestinationOwner"); + string destinationPath = testDirectory.CreateDirectory("DestinationOwner/Staging"); + StubFileHashService hashService = new() + { + HashForPath = path => + { + File.WriteAllText(path, "changed"); + return StubFileHashService.MatchingHash; + } + }; + S3ReusablePackageFileCopier copier = CreateReusableFileCopier(hashService); + + Func copy = () => copier.CopyUnchangedFilesAsync( + new OwnedContentPath(sourceOwner, sourcePath), + new OwnedContentPath(destinationOwner, destinationPath), + [new RemoteFileManifestEntry("payload.txt", StubFileHashService.MatchingHash, 7)], + CancellationToken.None); + + await copy.Should().ThrowAsync(); + (await File.ReadAllTextAsync(sourceFilePath, TestContext.Current.CancellationToken)).Should().Be("payload"); + File.Exists(Path.Combine(destinationPath, "payload.txt")).Should().BeFalse(); + } + + [Fact] + public async Task CopyUnchangedFilesAsync_UsesRecursiveManifestPathAndSkipsMissingIndexEntryAsync() + { + using TestDirectory testDirectory = new(); + string sourceOwner = testDirectory.CreateDirectory("SourceOwner"); + string sourcePath = testDirectory.CreateDirectory("SourceOwner/Latest"); + testDirectory.CreateFile("SourceOwner/Latest/readme.txt", "payload"); + testDirectory.CreateFile("SourceOwner/Latest/Nested/readme.txt", "payload"); + string destinationOwner = testDirectory.CreateDirectory("DestinationOwner"); + string destinationPath = testDirectory.CreateDirectory("DestinationOwner/Staging"); + S3ReusablePackageFileCopier copier = CreateReusableFileCopier( + new StubFileHashService { HashForPath = _ => StubFileHashService.MatchingHash }); + + await copier.CopyUnchangedFilesAsync( + new OwnedContentPath(sourceOwner, sourcePath), + new OwnedContentPath(destinationOwner, destinationPath), + [new RemoteFileManifestEntry("Nested/readme.txt", StubFileHashService.MatchingHash, 7)], + CancellationToken.None); + + File.Exists(Path.Combine(destinationPath, "readme.txt")).Should().BeFalse(); + (await File.ReadAllTextAsync(Path.Combine(destinationPath, "Nested", "readme.txt"), TestContext.Current.CancellationToken)) + .Should().Be("payload"); + } + + /// + /// An S3 ETag and a locally computed MD5 sum describe the same bytes in different letter case, so a case + /// difference must not cost the user a re-download of a file that is already correct. + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task CopyUnchangedFilesAsync_HashLetterCaseDiffers_ReusesFileAsync(bool manifestHashIsLowercase) + { + using TestDirectory testDirectory = new(); + string sourceOwner = testDirectory.CreateDirectory("SourceOwner"); + string sourcePath = testDirectory.CreateDirectory("SourceOwner/Latest"); + testDirectory.CreateFile("SourceOwner/Latest/payload.txt", "payload"); + string destinationOwner = testDirectory.CreateDirectory("DestinationOwner"); + string destinationPath = testDirectory.CreateDirectory("DestinationOwner/Staging"); + string lowercaseHash = StubFileHashService.MatchingHash.ToLowerInvariant(); + string manifestHash = manifestHashIsLowercase ? lowercaseHash : StubFileHashService.MatchingHash; + string computedHash = manifestHashIsLowercase ? StubFileHashService.MatchingHash : lowercaseHash; + S3ReusablePackageFileCopier copier = CreateReusableFileCopier( + new StubFileHashService { HashForPath = _ => computedHash }); + + await copier.CopyUnchangedFilesAsync( + new OwnedContentPath(sourceOwner, sourcePath), + new OwnedContentPath(destinationOwner, destinationPath), + [new RemoteFileManifestEntry("payload.txt", manifestHash, 7)], + CancellationToken.None); + + (await File.ReadAllTextAsync(Path.Combine(destinationPath, "payload.txt"), TestContext.Current.CancellationToken)).Should().Be("payload"); + } + + /// + /// Bytes already staged by an interrupted transfer are the authority for that file: reuse leaves them alone + /// instead of failing on the existing destination or paying to hash a copy it will not write. + /// + [Fact] + public async Task CopyUnchangedFilesAsync_DestinationAlreadyStaged_KeepsStagedFileAsync() + { + using TestDirectory testDirectory = new(); + string sourceOwner = testDirectory.CreateDirectory("SourceOwner"); + string sourcePath = testDirectory.CreateDirectory("SourceOwner/Latest"); + testDirectory.CreateFile("SourceOwner/Latest/Nested/readme.txt", "payload"); + string destinationOwner = testDirectory.CreateDirectory("DestinationOwner"); + string destinationPath = testDirectory.CreateDirectory("DestinationOwner/Staging"); + string stagedFilePath = testDirectory.CreateFile("DestinationOwner/Staging/Nested/readme.txt", "staged!"); + List hashedPaths = []; + S3ReusablePackageFileCopier copier = CreateReusableFileCopier( + new StubFileHashService + { + HashForPath = path => + { + hashedPaths.Add(path); + return StubFileHashService.MatchingHash; + } + }); + + Func copy = () => copier.CopyUnchangedFilesAsync( + new OwnedContentPath(sourceOwner, sourcePath), + new OwnedContentPath(destinationOwner, destinationPath), + [new RemoteFileManifestEntry("Nested/readme.txt", StubFileHashService.MatchingHash, 7)], + CancellationToken.None); + + await copy.Should().NotThrowAsync(); + (await File.ReadAllTextAsync(stagedFilePath, TestContext.Current.CancellationToken)).Should().Be("staged!"); + hashedPaths.Should().BeEmpty(); + } + + [Theory] + [InlineData(8, StubFileHashService.MatchingHash, StubFileHashService.MatchingHash)] + [InlineData(7, "unreliable", "unreliable")] + [InlineData(7, StubFileHashService.MatchingHash, StubFileHashService.MismatchedHash)] + public async Task CopyUnchangedFilesAsync_IntegrityFailure_DoesNotReuseAsync( + int manifestSize, + string manifestHash, + string computedHash) + { + using TestDirectory testDirectory = new(); + string sourceOwner = testDirectory.CreateDirectory("SourceOwner"); + string sourcePath = testDirectory.CreateDirectory("SourceOwner/Latest"); + testDirectory.CreateFile("SourceOwner/Latest/payload.txt", "payload"); + string destinationOwner = testDirectory.CreateDirectory("DestinationOwner"); + string destinationPath = testDirectory.CreateDirectory("DestinationOwner/Staging"); + S3ReusablePackageFileCopier copier = CreateReusableFileCopier( + new StubFileHashService { HashForPath = _ => computedHash }); + + await copier.CopyUnchangedFilesAsync( + new OwnedContentPath(sourceOwner, sourcePath), + new OwnedContentPath(destinationOwner, destinationPath), + [new RemoteFileManifestEntry("payload.txt", manifestHash, (ulong)manifestSize)], + CancellationToken.None); + + File.Exists(Path.Combine(destinationPath, "payload.txt")).Should().BeFalse(); + } + + /// + /// Each staging subfolder is re-validated as the walk descends into it, because the files copied at the level + /// above give another process a window to swap that subfolder for a link before anything is written into it. + /// + [Fact] + public async Task CopyUnchangedFilesAsync_StagingSubfolderLinkedDuringCopy_RejectsBeforeDescendingAsync() + { + using TestDirectory testDirectory = new(); + string sourceOwner = testDirectory.CreateDirectory("SourceOwner"); + string sourcePath = testDirectory.CreateDirectory("SourceOwner/Latest"); + string triggerPath = testDirectory.CreateFile("SourceOwner/Latest/trigger.txt", "payload"); + testDirectory.CreateFile("SourceOwner/Latest/Nested/Deeper/readme.txt", "payload"); + string destinationOwner = testDirectory.CreateDirectory("DestinationOwner"); + string destinationPath = testDirectory.CreateDirectory("DestinationOwner/Staging"); + string outsidePath = testDirectory.CreateDirectory("Outside"); + StubFileHashService hashService = new() + { + HashForPath = path => + { + if (string.Equals(path, triggerPath, StringComparison.OrdinalIgnoreCase)) + { + ReparsePointTestSupport.CreateDirectoryJunction( + Path.Combine(destinationPath, "Nested"), + outsidePath); + } + + return StubFileHashService.MatchingHash; + } + }; + S3ReusablePackageFileCopier copier = CreateReusableFileCopier(hashService); + + Func copy = () => copier.CopyUnchangedFilesAsync( + new OwnedContentPath(sourceOwner, sourcePath), + new OwnedContentPath(destinationOwner, destinationPath), + [ + new RemoteFileManifestEntry("trigger.txt", StubFileHashService.MatchingHash, 7), + new RemoteFileManifestEntry("Nested/Deeper/readme.txt", StubFileHashService.MatchingHash, 7) + ], + CancellationToken.None); + + await copy.Should().ThrowAsync() + .WithMessage("Package staging paths must not contain reparse points."); + Directory.EnumerateFileSystemEntries(outsidePath).Should().BeEmpty(); + } + + private static S3ReusablePackageFileCopier CreateReusableFileCopier(IFileHashService hashService) + { + return new S3ReusablePackageFileCopier(hashService, NullLogger.Instance); + } +} diff --git a/GenLauncherGO.Tests/Properties/AssemblyInfo.cs b/GenLauncherGO.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..220f5287 --- /dev/null +++ b/GenLauncherGO.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1,6 @@ +using System.Runtime.InteropServices; + +// Matches the production assemblies: the test P/Invokes target kernel32 only, so +// resolution is restricted to System32 rather than searching the test output +// directory first. +[assembly: DefaultDllImportSearchPaths(DllImportSearchPath.System32)] diff --git a/GenLauncherGO.Tests/Testing/ApplicationThemeScope.cs b/GenLauncherGO.Tests/Testing/ApplicationThemeScope.cs new file mode 100644 index 00000000..df3447b0 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/ApplicationThemeScope.cs @@ -0,0 +1,67 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Avalonia; +using Avalonia.Controls; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Restores the application-scoped launcher theme resources a test replaced. +/// +/// +/// Assigning LauncherRuntimeContext.Colors publishes into Application.Current.Resources, which the +/// headless session shares across every test in the Avalonia collection. Without this, a test that previews a +/// palette leaves it behind for whatever runs next. +/// +internal sealed class ApplicationThemeScope : IDisposable +{ + private static readonly string[] _themedKeyPrefixes = ["GenLauncher", "ListBox", "Dialog"]; + + private readonly Dictionary _previousValues = []; + + private readonly IResourceDictionary? _resources; + + public ApplicationThemeScope() + { + _resources = Application.Current?.Resources; + if (_resources is null) + { + return; + } + + foreach (object key in ThemedKeys(_resources)) + { + _previousValues[key] = _resources[key]; + } + } + + public void Dispose() + { + if (_resources is null) + { + return; + } + + foreach (object key in ThemedKeys(_resources)) + { + if (!_previousValues.ContainsKey(key)) + { + _resources.Remove(key); + } + } + + foreach ((object key, object? value) in _previousValues) + { + _resources[key] = value; + } + } + + private static List ThemedKeys(IResourceDictionary resources) + { + return resources.Keys + .Where(key => key is string name && + _themedKeyPrefixes.Any(prefix => name.StartsWith(prefix, StringComparison.Ordinal))) + .ToList(); + } +} diff --git a/GenLauncherGO.Tests/Testing/AvaloniaCollection.cs b/GenLauncherGO.Tests/Testing/AvaloniaCollection.cs new file mode 100644 index 00000000..a90f7a0f --- /dev/null +++ b/GenLauncherGO.Tests/Testing/AvaloniaCollection.cs @@ -0,0 +1,4 @@ +namespace GenLauncherGO.Tests.Testing; + +[CollectionDefinition("Avalonia", DisableParallelization = true)] +public sealed class AvaloniaCollection; diff --git a/GenLauncherGO.Tests/Testing/CompletedGameProcessLaunchOperation.cs b/GenLauncherGO.Tests/Testing/CompletedGameProcessLaunchOperation.cs new file mode 100644 index 00000000..ad4bb684 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/CompletedGameProcessLaunchOperation.cs @@ -0,0 +1,28 @@ +using System; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Contracts; + +namespace GenLauncherGO.Tests.Testing; + +internal sealed class CompletedGameProcessLaunchOperation : IGameProcessLaunchOperation +{ + public CompletedGameProcessLaunchOperation(bool succeeded, string executableName) + { + CurrentExecutableName = executableName; + Completion = Task.FromResult(succeeded); + } + + public string CurrentExecutableName { get; } + + public Task Completion { get; } + + public event EventHandler? CurrentExecutableNameChanged + { + add { } + remove { } + } + + public void ForceClose() + { + } +} diff --git a/GenLauncherGO.Tests/Testing/ControllableGameProcessLaunchOperation.cs b/GenLauncherGO.Tests/Testing/ControllableGameProcessLaunchOperation.cs new file mode 100644 index 00000000..8a4ce7d7 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/ControllableGameProcessLaunchOperation.cs @@ -0,0 +1,83 @@ +using System; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Infrastructure.Launching.Support; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// A launched process the test decides the lifetime of, for the states the launcher only shows while a game is +/// running. +/// +internal sealed class ControllableGameProcessLaunchOperation : IGameProcessLaunchOperation +{ + private readonly TaskCompletionSource _completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public ControllableGameProcessLaunchOperation(string executableName = "generals.exe") + { + CurrentExecutableName = executableName; + Completion = _completion.Task; + } + + public string CurrentExecutableName { get; private set; } + + public Task Completion { get; set; } + + public event EventHandler? CurrentExecutableNameChanged; + + public void ForceClose() + { + } + + public void RaiseCurrentExecutableNameChanged(string executableName) + { + CurrentExecutableName = executableName; + CurrentExecutableNameChanged?.Invoke(this, EventArgs.Empty); + } + + public void Complete(bool succeeded) + { + _completion.TrySetResult(succeeded); + } +} + +/// +/// The counterpart, which reports elapsed run time rather than a +/// success flag. +/// +internal sealed class ControllableProcessFamilyLaunchOperation : IProcessFamilyLaunchOperation +{ + private readonly TaskCompletionSource _completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public ControllableProcessFamilyLaunchOperation(string executableName = "generals.exe") + { + CurrentExecutableName = executableName; + Completion = _completion.Task; + } + + public string CurrentExecutableName { get; private set; } + + public Task Completion { get; set; } + + public int ForceCloseCount { get; private set; } + + public event EventHandler? CurrentExecutableNameChanged; + + public void ForceClose() + { + ForceCloseCount++; + } + + public void RaiseCurrentExecutableNameChanged(string executableName) + { + CurrentExecutableName = executableName; + CurrentExecutableNameChanged?.Invoke(this, EventArgs.Empty); + } + + public void Complete(TimeSpan runningDuration) + { + _completion.TrySetResult(runningDuration); + } +} diff --git a/GenLauncherGO.Tests/Testing/ControllablePackageDownloadService.cs b/GenLauncherGO.Tests/Testing/ControllablePackageDownloadService.cs new file mode 100644 index 00000000..b2418563 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/ControllablePackageDownloadService.cs @@ -0,0 +1,114 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.Core.Updating.Models; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// A download that stays in flight until the test ends it, so a test can observe the launcher while a package +/// download is running. +/// +internal sealed class ControllablePackageDownloadService : IPackageDownloadService +{ + private readonly TaskCompletionSource _completion = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + private IProgress? _progress; + + /// + /// Completes once the download has begun. + /// + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + /// + /// Completes once the download's cancellation token has been signalled. + /// + public TaskCompletionSource CancellationObserved { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); + + public int CallCount { get; private set; } + + public async Task DownloadAsync( + LauncherContent modification, + LauncherContentVersion version, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null) + { + CallCount++; + _progress = progress; + using CancellationTokenRegistration registration = + cancellationToken.Register(() => CancellationObserved.TrySetResult()); + Started.TrySetResult(); + + PackageDownloadResult? result = await _completion.Task; + if (result is not null) + { + return result; + } + + return cancellationToken.IsCancellationRequested + ? PackageDownloadResult.Canceled() + : PackageDownloadResult.Succeeded(); + } + + public void Report(PackageUpdateProgress progress) + { + if (_progress is null) + { + throw new InvalidOperationException("The download has not started, so nothing observes progress yet."); + } + + _progress.Report(progress); + } + + /// + /// Ends the download with an explicit terminal result. + /// + public void Complete(PackageDownloadResult result) + { + _completion.TrySetResult(result); + } + + /// + /// Ends the download the way production would: canceled when the token was signalled, otherwise succeeded. + /// + public void Release() + { + _completion.TrySetResult(null); + } + + /// + /// Ends the download by faulting it, for the failure paths the launcher has to survive. + /// + public void Throw(Exception exception) + { + _completion.TrySetException(exception); + } +} + +/// +/// A download that returns its configured result immediately. +/// +internal sealed class StubPackageDownloadService : IPackageDownloadService +{ + private readonly PackageDownloadResult _result; + + public StubPackageDownloadService(PackageDownloadResult result) + { + _result = result ?? throw new ArgumentNullException(nameof(result)); + } + + public Task DownloadAsync( + LauncherContent modification, + LauncherContentVersion version, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null) + { + return Task.FromResult(_result); + } +} diff --git a/GenLauncherGO.Tests/Testing/CultureScope.cs b/GenLauncherGO.Tests/Testing/CultureScope.cs new file mode 100644 index 00000000..094cba70 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/CultureScope.cs @@ -0,0 +1,40 @@ +using System; +using System.Globalization; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Sets the culture for one test and restores what was there before. +/// +/// +/// The thread defaults are process-wide, so a test that changes them without restoring them changes the result of +/// every test that runs afterwards in the same process. +/// +internal sealed class CultureScope : IDisposable +{ + private readonly CultureInfo _previousCulture = CultureInfo.CurrentCulture; + private readonly CultureInfo? _previousDefaultThreadCulture = CultureInfo.DefaultThreadCurrentCulture; + private readonly CultureInfo? _previousDefaultThreadUiCulture = CultureInfo.DefaultThreadCurrentUICulture; + private readonly CultureInfo _previousUiCulture = CultureInfo.CurrentUICulture; + + public CultureScope(string? cultureName = null, string? uiCultureName = null) + { + if (cultureName is not null) + { + CultureInfo.CurrentCulture = CultureInfo.GetCultureInfo(cultureName); + } + + if (uiCultureName is not null) + { + CultureInfo.CurrentUICulture = CultureInfo.GetCultureInfo(uiCultureName); + } + } + + public void Dispose() + { + CultureInfo.CurrentCulture = _previousCulture; + CultureInfo.CurrentUICulture = _previousUiCulture; + CultureInfo.DefaultThreadCurrentCulture = _previousDefaultThreadCulture; + CultureInfo.DefaultThreadCurrentUICulture = _previousDefaultThreadUiCulture; + } +} diff --git a/GenLauncherGO.Tests/Testing/DeploymentJournalWriter.cs b/GenLauncherGO.Tests/Testing/DeploymentJournalWriter.cs new file mode 100644 index 00000000..8c44b3fc --- /dev/null +++ b/GenLauncherGO.Tests/Testing/DeploymentJournalWriter.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Common; +using GenLauncherGO.Infrastructure.Launching.Support; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Writes the recovery journal an interrupted deployment would have left behind. +/// +internal static class DeploymentJournalWriter +{ + private const string JournalFileName = "journal.jsonl"; + + /// + /// Writes a journal whose header binds it to , followed by the supplied records. + /// + public static void Write(LauncherPaths paths, params DeploymentJournalRecord[] records) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentNullException.ThrowIfNull(records); + + Directory.CreateDirectory(paths.DeploymentDirectory); + List journal = + [ + DeploymentJournalRecord.DeploymentStarted( + "crash", + PhysicalDirectoryPath.ResolveExisting(paths.GameDirectory), + DeploymentStateStore.GetGameRootIdentity(paths.GameDirectory), + paths.Game) + ]; + journal.AddRange(records); + + var serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web); + File.WriteAllLines( + Path.Combine(paths.DeploymentDirectory, JournalFileName), + journal.Select(record => JsonSerializer.Serialize(record, serializerOptions))); + } + + /// + /// Builds the fingerprint the deployment engine would have recorded for a file holding + /// . + /// + public static DeploymentFileFingerprint FingerprintFrom(string contents) + { + byte[] bytes = Encoding.UTF8.GetBytes(contents); + return new DeploymentFileFingerprint(bytes.Length, Convert.ToHexString(SHA256.HashData(bytes))); + } +} diff --git a/GenLauncherGO.Tests/Testing/FakeGameInstallationService.cs b/GenLauncherGO.Tests/Testing/FakeGameInstallationService.cs new file mode 100644 index 00000000..a6b3e3b7 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/FakeGameInstallationService.cs @@ -0,0 +1,60 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Startup.Contracts; +using GenLauncherGO.Core.Startup.Models; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Answers installation validation from a rule the test supplies, and records what it was asked about. +/// +/// +/// The default rule accepts any nonblank directory as its own canonical path, which is the arrangement almost +/// every startup test wants: the interesting behavior is what the caller does with the verdict, not how it was +/// reached. +/// +internal sealed class FakeGameInstallationService : IGameInstallationService +{ + public Func ValidationRule { get; init; } = + (_, directory) => string.IsNullOrWhiteSpace(directory) + ? GameInstallationValidationResult.Invalid(GameInstallationValidationFailure.PathMissing) + : GameInstallationValidationResult.Valid(directory); + + /// + /// Returned from , or the supplied set when left unset. + /// + public LauncherInstallations? DiscoveredInstallations { get; set; } + + public GameInstallationLocation? ContainingInstallation { get; set; } + + public List<(SupportedGame Game, string? Directory, string ExecutableDirectory)> ValidateCalls { get; } = []; + + public List<(LauncherInstallations Current, string ExecutableDirectory)> DiscoverValidInstallationsCalls + { + get; + } = []; + + public GameInstallationLocation? FindContainingInstallation(string executableDirectory) + { + return ContainingInstallation; + } + + public GameInstallationValidationResult Validate( + SupportedGame game, + string? directory, + string executableDirectory) + { + ValidateCalls.Add((game, directory, executableDirectory)); + return ValidationRule(game, directory); + } + + public LauncherInstallations DiscoverValidInstallations( + LauncherInstallations current, + string executableDirectory) + { + DiscoverValidInstallationsCalls.Add((current, executableDirectory)); + return DiscoveredInstallations ?? current; + } +} diff --git a/GenLauncherGO.Tests/Testing/FakeHardLinkCreator.cs b/GenLauncherGO.Tests/Testing/FakeHardLinkCreator.cs new file mode 100644 index 00000000..2a5f1fab --- /dev/null +++ b/GenLauncherGO.Tests/Testing/FakeHardLinkCreator.cs @@ -0,0 +1,64 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using GenLauncherGO.Infrastructure.Launching.Support; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Stands in for the Windows hard-link creator so a test can decide whether linking succeeds, what happens at the +/// staging path while a link is being made, and when the deployment is cancelled. +/// +internal sealed class FakeHardLinkCreator : IHardLinkCreator +{ + private readonly WindowsHardLinkCreator _windowsHardLinkCreator = new(); + + /// + /// Whether a link attempt is allowed to succeed at all. + /// + public bool CanCreateHardLinks { get; init; } = true; + + /// + /// Whether an allowed attempt creates a real hard link, so the deployment's own identity checks run. + /// + public bool UseRealHardLinks { get; init; } = true; + + public bool PathsOnSameVolume { get; init; } = true; + + /// + /// Observes a volume comparison, which is where a test can mutate the paths under the caller. + /// + public Action? SameVolumeCheck { get; init; } + + /// + /// Observes a link attempt before it is made, with the target and source paths. + /// + public Action? CreateHook { get; init; } + + /// + /// Cancelled after each link attempt, for the deployment cancellation paths. + /// + public CancellationTokenSource? CancelOn { get; init; } + + public List<(string TargetPath, string SourcePath)> CreatedLinks { get; } = []; + + public bool ArePathsOnSameVolume(string firstPath, string secondPath) + { + SameVolumeCheck?.Invoke(firstPath, secondPath); + return PathsOnSameVolume; + } + + public bool TryCreateHardLink(string targetPath, string sourcePath) + { + CreateHook?.Invoke(targetPath, sourcePath); + bool created = CanCreateHardLinks && + (!UseRealHardLinks || _windowsHardLinkCreator.TryCreateHardLink(targetPath, sourcePath)); + if (created) + { + CreatedLinks.Add((targetPath, sourcePath)); + } + + CancelOn?.Cancel(); + return created; + } +} diff --git a/GenLauncherGO.Tests/Testing/FakeLaunchPreparationService.cs b/GenLauncherGO.Tests/Testing/FakeLaunchPreparationService.cs new file mode 100644 index 00000000..0e25a356 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/FakeLaunchPreparationService.cs @@ -0,0 +1,69 @@ +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Records launch preparation and can hold open, which is how a test observes the +/// launcher's busy state without racing a real deployment. +/// +internal sealed class FakeLaunchPreparationService : ILaunchPreparationService +{ + private TaskCompletionSource _resume = CreateResumedSource(); + + public List PrepareRequests { get; } = []; + + public List CleanupRequests { get; } = []; + + /// + /// Completes once has been entered. + /// + public TaskCompletionSource PrepareStarted { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public bool PrepareResult { get; init; } = true; + + public bool CleanupResult { get; init; } = true; + + /// + /// Holds the next call until is called. + /// + public void Pause() + { + _resume = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + } + + public void Resume() + { + _resume.TrySetResult(); + } + + public bool Prepare(LaunchPreparationRequest request, CancellationToken cancellationToken) + { + PrepareRequests.Add(request); + PrepareStarted.TrySetResult(); + _resume.Task.Wait(cancellationToken); + return PrepareResult; + } + + public bool Cleanup(LauncherPaths paths, CancellationToken cancellationToken) + { + CleanupRequests.Add(paths); + return CleanupResult; + } + + public bool Recover(LauncherPaths paths, CancellationToken cancellationToken) + { + return true; + } + + private static TaskCompletionSource CreateResumedSource() + { + var source = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + source.SetResult(); + return source; + } +} diff --git a/GenLauncherGO.Tests/Testing/FakeLauncherContentCatalog.cs b/GenLauncherGO.Tests/Testing/FakeLauncherContentCatalog.cs new file mode 100644 index 00000000..fa2863d1 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/FakeLauncherContentCatalog.cs @@ -0,0 +1,157 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Testing; + +internal sealed class FakeLauncherContentCatalog : ILauncherContentCatalog +{ + private readonly HashSet _repositoryContent = []; + + public List InitializationRequests { get; } = []; + + public List DownloadRequests { get; } = []; + + public List ChildManifestRequests { get; } = []; + + public List UninstalledVersions { get; } = []; + + public List DiscardedVersions { get; } = []; + + public List DiscardedContents { get; } = []; + + public int OriginalGameChildManifestReadCount { get; private set; } + + public int LocalDataUpdateCount { get; private set; } + + public int SaveCount { get; private set; } + + public Func? InitializationHandler + { + get; + set; + } + + public Func? OriginalGameChildManifestReadHandler { get; set; } + + public Func>? DownloadHandler { get; set; } + + public Func>? MetadataHandler { get; set; } + + public Func? ChildManifestReadHandler { get; set; } + + public Action? UninstallVersionHandler { get; set; } + + public Action? LocalDataUpdateHandler { get; set; } + + public Action? SaveHandler { get; set; } + + public LauncherData Data { get; set; } = new(); + + public LauncherContentVersion? Advertising { get; set; } + + public IReadOnlyList? RepositoryModificationNames { get; set; } + + public Task InitDataAsync( + LauncherContentCatalogInitializationRequest request, + CancellationToken cancellationToken) + { + InitializationRequests.Add(request); + return InitializationHandler?.Invoke(request, cancellationToken) ?? Task.CompletedTask; + } + + public Task ReadOriginalGameAddonsAndPatchesAsync(CancellationToken cancellationToken) + { + OriginalGameChildManifestReadCount++; + return OriginalGameChildManifestReadHandler?.Invoke(cancellationToken) ?? Task.CompletedTask; + } + + public Task GetRepositoryModificationMetadataAsync( + string name, + CancellationToken cancellationToken) + { + return MetadataHandler?.Invoke(name, cancellationToken) ?? + Task.FromException( + new InvalidOperationException($"No metadata result was configured for '{name}'.")); + } + + public async Task AddRepositoryModificationAsync( + string name, + CancellationToken cancellationToken) + { + DownloadRequests.Add(name); + LauncherContentVersion modification = await (DownloadHandler?.Invoke(name, cancellationToken) ?? + Task.FromException( + new InvalidOperationException( + $"No download result was configured for '{name}'."))); + Data.AddOrUpdate(modification); + _repositoryContent.Add(modification.ContentKey); + return modification; + } + + public Task ReadPatchesAndAddonsForModAsync( + LauncherContentKey modificationKey, + CancellationToken cancellationToken) + { + ChildManifestRequests.Add(modificationKey); + return ChildManifestReadHandler?.Invoke(modificationKey, cancellationToken) ?? Task.CompletedTask; + } + + public void UninstallVersion(LauncherContentKey contentKey) + { + UninstalledVersions.Add(contentKey); + LauncherContentVersion? version = Data.FindContent(contentKey)?.Versions + .FirstOrDefault(candidate => candidate.ContentKey == contentKey); + if (version is not null) + { + if (_repositoryContent.Contains(contentKey) || + version.EffectiveContentSourceKind.IsManagedRemote()) + { + version.Installation.Installed = false; + } + else + { + Data.DeleteVersion(contentKey); + } + } + + UninstallVersionHandler?.Invoke(contentKey); + UpdateLocalModificationsData(); + } + + public void DiscardVersion(LauncherContentKey contentKey) + { + DiscardedVersions.Add(contentKey); + Data.DeleteVersion(contentKey); + _repositoryContent.Remove(contentKey); + UpdateLocalModificationsData(); + } + + public void DiscardContent(LauncherContentKey contentKey) + { + DiscardedContents.Add(contentKey); + Data.DeleteContent(contentKey); + _repositoryContent.RemoveWhere(candidate => + candidate.ContentType == contentKey.ContentType && + string.Equals(candidate.ParentIdentity, contentKey.ParentIdentity, StringComparison.OrdinalIgnoreCase) && + string.Equals(candidate.Name, contentKey.Name, StringComparison.OrdinalIgnoreCase)); + UpdateLocalModificationsData(); + } + + public void UpdateLocalModificationsData() + { + LocalDataUpdateCount++; + LocalDataUpdateHandler?.Invoke(); + } + + public void SaveLauncherData() + { + SaveCount++; + SaveHandler?.Invoke(); + } +} diff --git a/GenLauncherGO.Tests/Testing/FakeModificationThemeCache.cs b/GenLauncherGO.Tests/Testing/FakeModificationThemeCache.cs new file mode 100644 index 00000000..ce9ebc95 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/FakeModificationThemeCache.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Keeps cached palettes in memory so tests can assert what was cached without touching disk. +/// +internal sealed class FakeModificationThemeCache : IModificationThemeCache +{ + private readonly Dictionary _entries = []; + + public IReadOnlyDictionary Entries => _entries; + + public void Save(LauncherContentKey contentKey, LauncherContentTheme theme) + { + _entries[contentKey] = theme; + } + + public LauncherContentTheme? Load(LauncherContentKey contentKey) + { + return _entries.TryGetValue(contentKey, out LauncherContentTheme? theme) ? theme : null; + } +} diff --git a/GenLauncherGO.Tests/Testing/FakeStringLocalizer.cs b/GenLauncherGO.Tests/Testing/FakeStringLocalizer.cs new file mode 100644 index 00000000..d1e5370c --- /dev/null +++ b/GenLauncherGO.Tests/Testing/FakeStringLocalizer.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.UI.Shared.Localization; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Resolves test localization keys from an in-memory dictionary whose keys must exist in the shipped neutral +/// resource. +/// +/// +/// Validating the keys is what stops a fixture from asserting text the product could never produce: a test that +/// seeds a key nobody ships still passes while the feature it claims to cover is broken. +/// +internal sealed class FakeStringLocalizer : ILauncherStringLocalizer +{ + /// + /// Creates a fallback value for missing keys. + /// + private readonly Func _fallback; + + /// + /// The configured localized values. + /// + private readonly IReadOnlyDictionary _values; + + /// + /// Initializes a new instance of the class. + /// + public FakeStringLocalizer() + : this(new Dictionary(StringComparer.Ordinal) + { + ["LatestVersion"] = "Latest version: " + }) + { + } + + /// + /// Initializes a new instance of the class. + /// + /// The explicit localized values. + public FakeStringLocalizer(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 FakeStringLocalizer( + IReadOnlyDictionary values, + Func fallback) + { + ArgumentNullException.ThrowIfNull(values); + ArgumentNullException.ThrowIfNull(fallback); + + ValidateKeys(values); + + _values = values; + _fallback = fallback; + } + + /// + public string this[string key] => _values.TryGetValue(key, out string? value) ? value : _fallback(key); + + /// + /// Builds a localizer from a shared set, replacing only the values this test is asserting on. + /// + public static FakeStringLocalizer Create( + IReadOnlyDictionary baseSet, + params (string Key, string Value)[] overrides) + { + ArgumentNullException.ThrowIfNull(baseSet); + ArgumentNullException.ThrowIfNull(overrides); + + var values = new Dictionary(baseSet, StringComparer.Ordinal); + foreach ((string key, string value) in overrides) + { + values[key] = value; + } + + return new FakeStringLocalizer(values); + } + + private static void ValidateKeys(IReadOnlyDictionary values) + { + var unknownKeys = values.Keys + .Where(key => !LocalizationResourceKeys.Contains(key)) + .Order(StringComparer.Ordinal) + .ToList(); + if (unknownKeys.Count == 0) + { + return; + } + + throw new ArgumentException( + "These localization keys are not in Strings.resx, so no production caller can ask for them: " + + $"{string.Join(", ", unknownKeys)}. Assert on a key the launcher actually ships.", + nameof(values)); + } +} diff --git a/GenLauncherGO.Tests/Testing/LocalizationResourceKeys.cs b/GenLauncherGO.Tests/Testing/LocalizationResourceKeys.cs new file mode 100644 index 00000000..8e7d2116 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/LocalizationResourceKeys.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Xml.Linq; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Reads the neutral localization resource once and caches its key set. +/// +/// +/// This is the authority a test localizer validates against, so a fixture cannot invent a key the product does +/// not ship and then assert a value nothing would ever produce. +/// +internal static class LocalizationResourceKeys +{ + private const string NeutralResourceFileName = "Strings.resx"; + + private const string ResourceFolderName = "LocalizationResources"; + + private static readonly Lazy> _keys = new(ReadNeutralKeys); + + public static IReadOnlySet All => _keys.Value; + + public static bool Contains(string key) + { + return All.Contains(key); + } + + private static IReadOnlySet ReadNeutralKeys() + { + string resourceFilePath = Path.Combine( + AppContext.BaseDirectory, + ResourceFolderName, + NeutralResourceFileName); + // Loaded through a stream rather than the path overload: that overload routes the path through + // System.Uri, which rejects a path long enough to exceed MAX_PATH with an unrelated + // "hostname could not be parsed" error. Test checkouts sit at arbitrary depths. + using FileStream resourceStream = File.OpenRead(resourceFilePath); + var document = XDocument.Load(resourceStream); + XElement root = document.Root ?? + throw new InvalidDataException($"{NeutralResourceFileName} has no root element."); + + return root.Elements("data") + .Select(element => element.Attribute("name")?.Value ?? + throw new InvalidDataException( + $"{NeutralResourceFileName} contains an unnamed resource.")) + .ToHashSet(StringComparer.Ordinal); + } +} diff --git a/GenLauncherGO.Tests/Testing/ManualTimeProvider.cs b/GenLauncherGO.Tests/Testing/ManualTimeProvider.cs new file mode 100644 index 00000000..a92c40f6 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/ManualTimeProvider.cs @@ -0,0 +1,47 @@ +using System; +using System.Threading; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// A clock that only moves when a test advances it. +/// +/// +/// Serves both the elapsed-time readings taken through and the wall-clock +/// readings taken through , so one fake covers every production clock seam. +/// +internal sealed class ManualTimeProvider : TimeProvider +{ + private readonly DateTimeOffset _startUtc; + + private long _timestamp; + + public ManualTimeProvider() + : this(new DateTimeOffset(2026, 6, 21, 12, 0, 0, TimeSpan.Zero)) + { + } + + public ManualTimeProvider(DateTimeOffset startUtc) + { + _startUtc = startUtc; + } + + public override long TimestampFrequency => TimeSpan.TicksPerSecond; + + public override long GetTimestamp() + { + return Interlocked.Read(ref _timestamp); + } + + public override DateTimeOffset GetUtcNow() + { + return _startUtc.AddTicks(Interlocked.Read(ref _timestamp)); + } + + public void Advance(TimeSpan elapsed) + { + ArgumentOutOfRangeException.ThrowIfLessThan(elapsed, TimeSpan.Zero); + + Interlocked.Add(ref _timestamp, elapsed.Ticks); + } +} diff --git a/GenLauncherGO.Tests/Testing/QueueHttpMessageHandler.cs b/GenLauncherGO.Tests/Testing/QueueHttpMessageHandler.cs new file mode 100644 index 00000000..82ab744f --- /dev/null +++ b/GenLauncherGO.Tests/Testing/QueueHttpMessageHandler.cs @@ -0,0 +1,41 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; + +namespace GenLauncherGO.Tests.Testing; + +internal sealed class QueueHttpMessageHandler : HttpMessageHandler +{ + private readonly Queue> _responses = new(); + + public IEnumerable Methods => Requests.Select(request => request.Method); + + public IEnumerable RangeHeaders => + Requests.Select(request => request.Headers.Range?.ToString()); + + public List Requests { get; } = []; + + public void Enqueue(Func responseFactory) + { + ArgumentNullException.ThrowIfNull(responseFactory); + + _responses.Enqueue(responseFactory); + } + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + Requests.Add(request); + if (!_responses.TryDequeue(out Func? responseFactory)) + { + throw new InvalidOperationException( + $"No response was configured for {request.Method} {request.RequestUri}."); + } + + return Task.FromResult(responseFactory(request)); + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingArchiveExtractor.cs b/GenLauncherGO.Tests/Testing/RecordingArchiveExtractor.cs new file mode 100644 index 00000000..58da9123 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingArchiveExtractor.cs @@ -0,0 +1,34 @@ +using System; +using System.Threading; +using GenLauncherGO.Infrastructure.Archives.Contracts; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Records what was asked of the extractor and lets the test decide what lands in the destination. +/// +internal sealed class RecordingArchiveExtractor : IArchiveExtractor +{ + public string? ArchiveFilePath { get; private set; } + + public string? DestinationDirectory { get; private set; } + + public bool? ConvertBigFilesToGib { get; private set; } + + /// + /// Populates the destination directory, which is given as its only argument. + /// + public Action? ExtractHandler { get; init; } + + public void ExtractToDirectory( + string archiveFilePath, + string destinationDirectory, + bool convertBigFilesToGib = false, + CancellationToken cancellationToken = default) + { + ArchiveFilePath = archiveFilePath; + DestinationDirectory = destinationDirectory; + ConvertBigFilesToGib = convertBigFilesToGib; + ExtractHandler?.Invoke(destinationDirectory); + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingAtomicFileWriter.cs b/GenLauncherGO.Tests/Testing/RecordingAtomicFileWriter.cs new file mode 100644 index 00000000..3ecf0db3 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingAtomicFileWriter.cs @@ -0,0 +1,46 @@ +using System; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Persistence.Services; + +namespace GenLauncherGO.Tests.Testing; + +internal sealed class RecordingAtomicFileWriter : IAtomicFileWriter +{ + public string? DestinationPath { get; private set; } + + public string? Contents { get; private set; } + + public CancellationToken? CancellationToken { get; private set; } + + public bool WasWriteAsyncCalled { get; private set; } + + public void WriteText(string destinationPath, string contents) + { + throw new NotSupportedException("This recorder only observes WriteAsync."); + } + + public async Task WriteAsync( + string destinationPath, + Func writeTemporaryFileAsync, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + await using var stream = new MemoryStream(); + await writeTemporaryFileAsync(stream, cancellationToken); + DestinationPath = destinationPath; + Contents = Encoding.UTF8.GetString(stream.ToArray()); + CancellationToken = cancellationToken; + WasWriteAsyncCalled = true; + } + + public Task WriteFileIfMissingAsync( + string destinationPath, + Func writeTemporaryFileAsync, + CancellationToken cancellationToken) + { + throw new NotSupportedException("This recorder only observes WriteAsync."); + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingFileDownloader.cs b/GenLauncherGO.Tests/Testing/RecordingFileDownloader.cs new file mode 100644 index 00000000..6e412167 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingFileDownloader.cs @@ -0,0 +1,52 @@ +using System; +using System.Collections.Concurrent; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Records download requests and, unless a handler takes over, writes a file of the requested length so the +/// caller's own file handling runs against real bytes. +/// +internal sealed class RecordingFileDownloader : IResumableFileDownloader +{ + private const byte FillerByte = (byte)'x'; + + public ConcurrentQueue Requests { get; } = new(); + + public Func? Handler { get; init; } + + public Func?, CancellationToken, Task>? ProgressHandler + { + get; + init; + } + + public async Task DownloadFileAsync( + DownloadFileRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + Requests.Enqueue(request); + if (ProgressHandler is not null) + { + await ProgressHandler(request, progress, cancellationToken); + return; + } + + if (Handler is not null) + { + await Handler(request, cancellationToken); + return; + } + + byte[] payload = new byte[checked((int)request.ExpectedBytes.GetValueOrDefault())]; + Array.Fill(payload, FillerByte); + Directory.CreateDirectory(Path.GetDirectoryName(request.DestinationFilePath)!); + await File.WriteAllBytesAsync(request.DestinationFilePath, payload, cancellationToken); + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingLauncherContentStateStore.cs b/GenLauncherGO.Tests/Testing/RecordingLauncherContentStateStore.cs new file mode 100644 index 00000000..1364a950 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingLauncherContentStateStore.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Contracts; +using GenLauncherGO.Infrastructure.Mods.Models; + +namespace GenLauncherGO.Tests.Testing; + +internal sealed class RecordingLauncherContentStateStore : ILauncherContentStateStore +{ + public LauncherContentState StateToLoad { get; set; } = new(); + + public Dictionary StatesToLoadByGame { get; } = []; + + public List LoadedPaths { get; } = []; + + public List SavedStates { get; } = []; + + public List SavedPaths { get; } = []; + + public Action? SaveHandler { get; set; } + + public LauncherContentState Load(LauncherPaths paths) + { + LoadedPaths.Add(paths); + return StatesToLoadByGame.TryGetValue(paths.Game, out LauncherContentState? state) + ? state + : StateToLoad; + } + + public void Save(LauncherPaths paths, LauncherContentState state) + { + SavedPaths.Add(paths); + SavedStates.Add(state); + SaveHandler?.Invoke(state); + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingLauncherDialogService.cs b/GenLauncherGO.Tests/Testing/RecordingLauncherDialogService.cs new file mode 100644 index 00000000..4294c43a --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingLauncherDialogService.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Avalonia.Controls; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Models; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Records every dialog the launcher raised and answers each one from a configured result. +/// +internal sealed class RecordingLauncherDialogService : ILauncherDialogService +{ + public List InfoRequests { get; } = []; + + public List ErrorRequests { get; } = []; + + public List<(LauncherInfoDialogRequest Request, string? ContinueText)> WarningConfirmationRequests { get; } = []; + + public List IntegrityReviewRequests { get; } = []; + + public bool WarningConfirmationResult { get; init; } + + public bool IntegrityReviewResult { get; init; } + + public Task ShowInfoAsync(LauncherInfoDialogRequest request, Window? owner = null) + { + InfoRequests.Add(request); + return Task.CompletedTask; + } + + public Task ShowInfoActionAsync( + LauncherInfoDialogRequest request, + string actionText, + Window? owner = null) + { + return Task.FromException(Unexpected(nameof(ShowInfoActionAsync))); + } + + public Task ShowErrorAsync(LauncherInfoDialogRequest request, Window? owner = null) + { + ErrorRequests.Add(request); + return Task.CompletedTask; + } + + public Task ShowWarningConfirmationAsync( + LauncherInfoDialogRequest request, + string? continueText = null, + Window? owner = null) + { + WarningConfirmationRequests.Add((request, continueText)); + return Task.FromResult(WarningConfirmationResult); + } + + public Task ShowModificationSelectionAsync( + IReadOnlyList modificationNames, + Window? owner = null) + { + return Task.FromException(Unexpected(nameof(ShowModificationSelectionAsync))); + } + + public Task ShowManualModificationImportAsync( + IReadOnlyList files, + Window? owner = null) + { + return Task.FromException( + Unexpected(nameof(ShowManualModificationImportAsync))); + } + + public Task ShowIntegrityReviewAsync(ContentIntegrityReport report, Window? owner = null) + { + IntegrityReviewRequests.Add(report); + return Task.FromResult(IntegrityReviewResult); + } + + /// + /// Fails a test that raised a dialog this fake answers for nobody, rather than handing back a default that + /// reads like a real user choice. Give the dialog a recorded result here once a test actually needs one. + /// + private static InvalidOperationException Unexpected(string dialogName) + { + return new InvalidOperationException($"No test configures {dialogName} on this fake."); + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingLauncherPreferencesService.cs b/GenLauncherGO.Tests/Testing/RecordingLauncherPreferencesService.cs new file mode 100644 index 00000000..8a812cff --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingLauncherPreferencesService.cs @@ -0,0 +1,55 @@ +using System; +using System.Collections.Generic; +using GenLauncherGO.Core.Settings.Contracts; +using GenLauncherGO.Core.Settings.Exceptions; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Infrastructure.Settings.Support; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Records preference updates and applies the same no-op policy as PreferencesService.Update: a value that +/// normalizes to what is already current is neither recorded nor announced. +/// +/// +/// Normalization decides only whether the update is a change. What is recorded is the value the caller passed, +/// because a test seeds directly and a fake that silently repaired those values would hide +/// the state the caller is being tested against. +/// +internal sealed class RecordingLauncherPreferencesService : ILauncherPreferencesService +{ + public RecordingLauncherPreferencesService(LauncherPreferences current) + { + Current = current; + } + + public LauncherPreferences Current { get; private set; } + + public List Updates { get; } = []; + + public int UpdateCount => Updates.Count; + + public LauncherPreferencesPersistenceException? UpdateFailure { get; init; } + + public event EventHandler? PreferencesChanged; + + public void Update(LauncherPreferences preferences) + { + ArgumentNullException.ThrowIfNull(preferences); + + if (LauncherPreferencesDocumentMapper.Normalize(preferences) == + LauncherPreferencesDocumentMapper.Normalize(Current)) + { + return; + } + + if (UpdateFailure is not null) + { + throw UpdateFailure; + } + + Current = preferences; + Updates.Add(preferences); + PreferencesChanged?.Invoke(this, preferences); + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingLocalLauncherContentService.cs b/GenLauncherGO.Tests/Testing/RecordingLocalLauncherContentService.cs new file mode 100644 index 00000000..716a3622 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingLocalLauncherContentService.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Mods.Contracts; + +namespace GenLauncherGO.Tests.Testing; + +internal sealed class RecordingLocalLauncherContentService : ILocalLauncherContentService +{ + public IReadOnlyList InstalledVersions { get; set; } = + Array.Empty(); + + public List<(LauncherPaths Paths, LauncherContentKey ContentKey)> DeletedVersions { get; } = []; + + public List<(LauncherPaths Paths, LauncherContentKey ContentKey)> DeletedContents { get; } = []; + + public List EmptyPackageBackupCleanupRequests { get; } = []; + + /// + /// Records image-deletion requests with the catalog names as they stood when the request was made. + /// + /// + /// Data is a live reference the caller keeps mutating, so only ContentNames can answer what the + /// catalog looked like at the moment deletion was decided. Data is retained for identity assertions. + /// + public List<( + LauncherPaths Paths, + LauncherContentKey ContentKey, + LauncherData Data, + IReadOnlyList ContentNames)> ImageDeletionRequests + { get; } = []; + + public IReadOnlyList FindInstalledVersions(LauncherPaths paths) + { + return InstalledVersions; + } + + public void DeleteEmptyPackageBackupDirectories(LauncherPaths paths) + { + EmptyPackageBackupCleanupRequests.Add(paths); + } + + public void DeleteVersion(LauncherPaths paths, LauncherContentKey contentKey) + { + DeletedVersions.Add((paths, contentKey)); + } + + public void DeleteContent(LauncherPaths paths, LauncherContentKey contentKey) + { + DeletedContents.Add((paths, contentKey)); + } + + public void DeleteImagesIfUnused( + LauncherPaths paths, + LauncherContentKey contentKey, + LauncherData launcherData) + { + ImageDeletionRequests.Add((paths, contentKey, launcherData, SnapshotContentNames(launcherData))); + } + + private static IReadOnlyList SnapshotContentNames(LauncherData launcherData) + { + return launcherData.Modifications + .Concat(launcherData.Patches) + .Concat(launcherData.Addons) + .Select(content => content.Name) + .ToList(); + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingLogger.cs b/GenLauncherGO.Tests/Testing/RecordingLogger.cs new file mode 100644 index 00000000..f2196d3f --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingLogger.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.Tests.Testing; + +internal sealed class RecordingLogger : ILogger +{ + public List Entries { get; } = []; + + 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 RecordingLogEntry(logLevel, formatter(state, exception), exception)); + } + + private sealed class NullScope : IDisposable + { + public static readonly NullScope Instance = new(); + + public void Dispose() + { + } + } +} + +internal sealed record RecordingLogEntry( + LogLevel LogLevel, + string Message, + Exception? Exception); diff --git a/GenLauncherGO.Tests/Testing/RecordingProgress.cs b/GenLauncherGO.Tests/Testing/RecordingProgress.cs new file mode 100644 index 00000000..53a91a5e --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingProgress.cs @@ -0,0 +1,21 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Collects progress reports. Production reports from whatever thread the transfer is on, so the reports are +/// collected through a concurrent queue rather than a list. +/// +internal sealed class RecordingProgress : IProgress +{ + private readonly ConcurrentQueue _reports = new(); + + public IReadOnlyList Reports => _reports.ToArray(); + + public void Report(T value) + { + _reports.Enqueue(value); + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingRemoteAssetDownloader.cs b/GenLauncherGO.Tests/Testing/RecordingRemoteAssetDownloader.cs new file mode 100644 index 00000000..c79223c0 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingRemoteAssetDownloader.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Remote.Contracts; + +namespace GenLauncherGO.Tests.Testing; + +internal sealed class RecordingRemoteAssetDownloader : IRemoteAssetDownloader +{ + private readonly ConcurrentQueue<(Uri SourceUri, string DestinationFilePath)> _calls = new(); + + public IReadOnlyList<(Uri SourceUri, string DestinationFilePath)> Calls => _calls.ToArray(); + + public Func? Handler { get; set; } + + public Task DownloadIfMissingAsync( + Uri sourceUri, + string destinationFilePath, + CancellationToken cancellationToken) + { + _calls.Enqueue((sourceUri, destinationFilePath)); + return Handler?.Invoke(sourceUri, destinationFilePath, cancellationToken) ?? Task.CompletedTask; + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingS3ObjectManifestReader.cs b/GenLauncherGO.Tests/Testing/RecordingS3ObjectManifestReader.cs new file mode 100644 index 00000000..f173460b --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingS3ObjectManifestReader.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Returns enqueued manifests in order and records what was asked for. +/// +/// +/// Once the queue drains, the last manifest is returned again: listing the same prefix twice has to produce the +/// same objects, so a caller that re-reads must not silently see an empty bucket. +/// +internal sealed class RecordingS3ObjectManifestReader : IS3ObjectManifestReader +{ + private readonly Queue> _manifests = new(); + + private IReadOnlyList _lastManifest = Array.Empty(); + + public List Requests { get; } = []; + + public void Enqueue(params RemoteFileManifestEntry[] files) + { + ArgumentNullException.ThrowIfNull(files); + + _manifests.Enqueue(files); + } + + public Task> ReadManifestAsync( + S3ObjectManifestRequest request, + CancellationToken cancellationToken) + { + Requests.Add(request); + if (_manifests.TryDequeue(out IReadOnlyList? manifest)) + { + _lastManifest = manifest; + } + + return Task.FromResult(_lastManifest); + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingS3PackageUpdater.cs b/GenLauncherGO.Tests/Testing/RecordingS3PackageUpdater.cs new file mode 100644 index 00000000..7dc9e584 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingS3PackageUpdater.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Records both S3 package operations, which are separate contracts a caller can confuse: a full update replaces +/// the installed folder, a repair rewrites named files in place. +/// +internal sealed class RecordingS3PackageUpdater : IS3PackageUpdater +{ + public List UpdateRequests { get; } = []; + + public List RepairRequests { get; } = []; + + public List PauseControllers { get; } = []; + + /// + /// Reported once from each operation when set. + /// + public PackageUpdateProgress? ProgressToReport { get; init; } + + public Task UpdateAsync( + S3PackageUpdateRequest request, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null) + { + UpdateRequests.Add(request); + PauseControllers.Add(pauseController); + ReportProgress(progress); + return Task.CompletedTask; + } + + public Task RepairFilesAsync( + S3PackageFileRepairRequest request, + IProgress? progress, + CancellationToken cancellationToken) + { + RepairRequests.Add(request); + ReportProgress(progress); + return Task.CompletedTask; + } + + private void ReportProgress(IProgress? progress) + { + if (ProgressToReport is not null) + { + progress?.Report(ProgressToReport); + } + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingSingleFilePackageUpdater.cs b/GenLauncherGO.Tests/Testing/RecordingSingleFilePackageUpdater.cs new file mode 100644 index 00000000..c15b9ffd --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingSingleFilePackageUpdater.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Records single-file package updates, including the pause controller the caller forwarded, which is the only +/// evidence that a suspended download can be resumed. +/// +internal sealed class RecordingSingleFilePackageUpdater : ISingleFilePackageUpdater +{ + public List<(DownloadFileMetadata Metadata, PackageUpdatePathSet Paths)> Requests { get; } = []; + + public List PauseControllers { get; } = []; + + /// + /// Reported once from each update when set. + /// + public PackageUpdateProgress? ProgressToReport { get; init; } + + /// + /// Takes over the update body when set, for the cancellation and failure paths. + /// + public Func?, CancellationToken, Task>? Update { get; init; } + + public Task UpdateAsync( + DownloadFileMetadata metadata, + PackageUpdatePathSet paths, + IProgress? progress, + CancellationToken cancellationToken, + PackageDownloadPauseController? pauseController = null) + { + Requests.Add((metadata, paths)); + PauseControllers.Add(pauseController); + if (ProgressToReport is not null) + { + progress?.Report(ProgressToReport); + } + + return Update?.Invoke(metadata, progress, cancellationToken) ?? Task.CompletedTask; + } +} diff --git a/GenLauncherGO.Tests/Testing/RecordingStartupDialogService.cs b/GenLauncherGO.Tests/Testing/RecordingStartupDialogService.cs new file mode 100644 index 00000000..3e087550 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/RecordingStartupDialogService.cs @@ -0,0 +1,48 @@ +using System.Collections.Generic; +using System.Threading.Tasks; +using GenLauncherGO.UI.Features.Startup.Contracts; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Records the startup dialogs the launcher showed before the main dialog service exists. +/// +internal sealed class RecordingStartupDialogService : IStartupDialogService +{ + /// + /// Every message shown, whatever dialog carried it. + /// + public List Messages { get; } = []; + + /// + /// The titled messages only, so a test can assert the title as well as the body. + /// + public List<(string Title, string Message)> TitledMessages { get; } = []; + + public List<(string Title, string Message)> RetryCancelWarnings { get; } = []; + + /// + /// The answer every retry prompt gets. + /// + public bool RetryResult { get; init; } + + public Task ShowMessageAsync(string message) + { + Messages.Add(message); + return Task.CompletedTask; + } + + public Task ShowMessageAsync(string title, string message) + { + Messages.Add(message); + TitledMessages.Add((title, message)); + return Task.CompletedTask; + } + + public Task ShowRetryCancelWarningAsync(string title, string message) + { + Messages.Add(message); + RetryCancelWarnings.Add((title, message)); + return Task.FromResult(RetryResult); + } +} diff --git a/GenLauncherGO.Tests/Testing/ReparsePointTestSupport.cs b/GenLauncherGO.Tests/Testing/ReparsePointTestSupport.cs new file mode 100644 index 00000000..ae5fffdc --- /dev/null +++ b/GenLauncherGO.Tests/Testing/ReparsePointTestSupport.cs @@ -0,0 +1,172 @@ +using System; +using System.ComponentModel; +using System.IO; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Creates directory junctions for the reparse-point safety tests. +/// +/// Production rejects unsafe paths by testing and never inspects the +/// reparse tag, so a junction exercises the same checks as a symbolic link. Junctions need no elevation and no +/// Windows Developer Mode, which keeps these safety tests running for every contributor instead of skipping on +/// accounts that cannot create symbolic links. +/// +/// +internal static class ReparsePointTestSupport +{ + private const uint IoReparseTagMountPoint = 0xA0000003; + private const uint FsctlSetReparsePoint = 0x000900A4; + private const uint GenericWrite = 0x40000000; + private const uint OpenExisting = 3; + private const uint FileFlagBackupSemantics = 0x02000000; + private const uint FileFlagOpenReparsePoint = 0x00200000; + + /// + /// Creates a directory junction at that resolves to + /// . + /// + public static void CreateDirectoryJunction(string junctionPath, string targetPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(junctionPath); + ArgumentException.ThrowIfNullOrWhiteSpace(targetPath); + + string fullTargetPath = Path.GetFullPath(targetPath); + Directory.CreateDirectory(fullTargetPath); + Directory.CreateDirectory(junctionPath); + + byte[] reparseData = BuildMountPointReparseData(fullTargetPath); + + using SafeFileHandle handle = CreateFile( + junctionPath, + GenericWrite, + 0, + IntPtr.Zero, + OpenExisting, + FileFlagBackupSemantics | FileFlagOpenReparsePoint, + IntPtr.Zero); + if (handle.IsInvalid) + { + throw new Win32Exception( + Marshal.GetLastWin32Error(), + $"Could not open '{junctionPath}' to write its junction data."); + } + + if (!DeviceIoControl( + handle, + FsctlSetReparsePoint, + reparseData, + reparseData.Length, + IntPtr.Zero, + 0, + out int _, + IntPtr.Zero)) + { + throw new Win32Exception( + Marshal.GetLastWin32Error(), + $"Could not set the junction reparse point on '{junctionPath}'."); + } + } + + /// + /// Creates a junction at pointing at a directory outside the launcher-owned + /// tree that holds one canary file, which is the arrangement every containment test needs. + /// + /// + /// The canary is what proves a rejected operation stopped before it followed the link: asserting only that the + /// junction survived cannot tell a refusal apart from a delete that happened to leave the link behind. + /// + public static ProtectedJunction CreateJunctionToProtectedTarget( + TestDirectory directory, + string junctionPath, + string targetRelativePath = "ExternalTarget", + string canaryFileName = "target.txt") + { + ArgumentNullException.ThrowIfNull(directory); + ArgumentException.ThrowIfNullOrWhiteSpace(junctionPath); + + const string CanaryContents = "target"; + + string targetDirectory = directory.CreateDirectory(targetRelativePath); + string canaryFilePath = directory.CreateFile( + Path.Combine(targetRelativePath, canaryFileName), + CanaryContents); + CreateDirectoryJunction(junctionPath, targetDirectory); + + return new ProtectedJunction(junctionPath, targetDirectory, canaryFilePath, CanaryContents); + } + + /// + /// Builds a REPARSE_DATA_BUFFER describing a mount point. The substitute name uses the NT object-manager prefix + /// that the mount-point format requires; the print name is the display path. + /// + private static byte[] BuildMountPointReparseData(string fullTargetPath) + { + const int ReparseHeaderLength = 8; + const int MountPointHeaderLength = 8; + const int TerminatorLength = 2; + + byte[] substituteName = Encoding.Unicode.GetBytes($@"\??\{fullTargetPath}"); + byte[] printName = Encoding.Unicode.GetBytes(fullTargetPath); + + int pathBufferLength = substituteName.Length + TerminatorLength + printName.Length + TerminatorLength; + byte[] buffer = new byte[ReparseHeaderLength + MountPointHeaderLength + pathBufferLength]; + + BitConverter.GetBytes(IoReparseTagMountPoint).CopyTo(buffer, 0); + BitConverter.GetBytes((ushort)(MountPointHeaderLength + pathBufferLength)).CopyTo(buffer, 4); + BitConverter.GetBytes((ushort)0).CopyTo(buffer, 6); + BitConverter.GetBytes((ushort)0).CopyTo(buffer, 8); + BitConverter.GetBytes((ushort)substituteName.Length).CopyTo(buffer, 10); + BitConverter.GetBytes((ushort)(substituteName.Length + TerminatorLength)).CopyTo(buffer, 12); + BitConverter.GetBytes((ushort)printName.Length).CopyTo(buffer, 14); + + substituteName.CopyTo(buffer, ReparseHeaderLength + MountPointHeaderLength); + printName.CopyTo( + buffer, + ReparseHeaderLength + MountPointHeaderLength + substituteName.Length + TerminatorLength); + + return buffer; + } + + [DllImport("kernel32.dll", EntryPoint = "CreateFileW", SetLastError = true, CharSet = CharSet.Unicode)] + private static extern SafeFileHandle CreateFile( + string lpFileName, + uint dwDesiredAccess, + uint dwShareMode, + IntPtr lpSecurityAttributes, + uint dwCreationDisposition, + uint dwFlagsAndAttributes, + IntPtr hTemplateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + private static extern bool DeviceIoControl( + SafeFileHandle hDevice, + uint dwIoControlCode, + byte[] lpInBuffer, + int nInBufferSize, + IntPtr lpOutBuffer, + int nOutBufferSize, + out int lpBytesReturned, + IntPtr lpOverlapped); +} + +/// +/// A junction and the outside directory it resolves to, with the canary that proves the target was untouched. +/// +internal sealed record ProtectedJunction( + string JunctionPath, + string TargetDirectory, + string CanaryFilePath, + string CanaryContents) +{ + /// + /// Reads the canary as it stands now, which must still equal . + /// + public string ReadCanary() + { + return File.ReadAllText(CanaryFilePath); + } +} diff --git a/GenLauncherGO.Tests/Testing/StaTestRunner.cs b/GenLauncherGO.Tests/Testing/StaTestRunner.cs new file mode 100644 index 00000000..b9387301 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/StaTestRunner.cs @@ -0,0 +1,61 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Headless; +using GenLauncherGO.UI.Features.Startup; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Runs Avalonia-dependent test code in an isolated headless UI session. +/// +internal static class StaTestRunner +{ + private static readonly Lock _sessionGate = new(); + + private static readonly Lazy _session = + new(() => HeadlessUnitTestSession.StartNew(typeof(LauncherAvaloniaApplication))); + + public static void Run(Action action) + { + ArgumentNullException.ThrowIfNull(action); + + lock (_sessionGate) + { + _session.Value.Dispatch(action, CancellationToken.None).GetAwaiter().GetResult(); + } + } + + public static void Run(Func action) + { + ArgumentNullException.ThrowIfNull(action); + + lock (_sessionGate) + { + _session.Value.Dispatch( + async () => + { + await action(); + return true; + }, + CancellationToken.None) + .GetAwaiter() + .GetResult(); + } + } + + public static TResult Run(Func function) + { + ArgumentNullException.ThrowIfNull(function); + + if (typeof(Task).IsAssignableFrom(typeof(TResult))) + { + throw new InvalidOperationException("Use the Func overload for asynchronous work."); + } + + lock (_sessionGate) + { + return _session.Value.Dispatch(function, CancellationToken.None).GetAwaiter().GetResult(); + } + } +} diff --git a/GenLauncherGO.Tests/Testing/StaTestRunnerTests.cs b/GenLauncherGO.Tests/Testing/StaTestRunnerTests.cs new file mode 100644 index 00000000..43b52aec --- /dev/null +++ b/GenLauncherGO.Tests/Testing/StaTestRunnerTests.cs @@ -0,0 +1,71 @@ +using System; +using System.Threading.Tasks; +using Avalonia.Threading; + +namespace GenLauncherGO.Tests.Testing; + +[Collection("Avalonia")] +public sealed class StaTestRunnerTests +{ + [Fact] + public void Run_Action_ExecutesOnAvaloniaUiThread() + { + bool ranOnDispatcherThread = false; + + StaTestRunner.Run(() => { ranOnDispatcherThread = Dispatcher.UIThread.CheckAccess(); }); + + ranOnDispatcherThread.Should().BeTrue(); + } + + [Fact] + public void Run_AsyncAction_PreservesDispatcherAffinityAcrossAwait() + { + bool keptDispatcherAffinity = false; + + StaTestRunner.Run(async () => + { + int dispatcherThreadId = Environment.CurrentManagedThreadId; + var dispatchedThreadId = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + Dispatcher.UIThread.Post(() => + dispatchedThreadId.TrySetResult(Environment.CurrentManagedThreadId)); + + int callbackThreadId = await dispatchedThreadId.Task; + + keptDispatcherAffinity = + Dispatcher.UIThread.CheckAccess() && + callbackThreadId == dispatcherThreadId && + Environment.CurrentManagedThreadId == dispatcherThreadId; + }); + + keptDispatcherAffinity.Should().BeTrue(); + } + + [Fact] + public void Run_ActionThrows_PropagatesException() + { + static void FailingAction() + { + throw new InvalidOperationException("dispatched failure"); + } + + Action run = () => StaTestRunner.Run(FailingAction); + + run.Should().Throw().WithMessage("dispatched failure"); + } + + [Fact] + public void Run_AsyncActionThrowsAfterAwait_PropagatesException() + { + static async Task FailingActionAsync() + { + await Task.Yield(); + + throw new InvalidOperationException("dispatched failure after await"); + } + + Action run = () => StaTestRunner.Run(FailingActionAsync); + + run.Should().Throw().WithMessage("dispatched failure after await"); + } +} diff --git a/GenLauncherGO.Tests/Testing/StubDownloadFileMetadataReader.cs b/GenLauncherGO.Tests/Testing/StubDownloadFileMetadataReader.cs new file mode 100644 index 00000000..75d30069 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/StubDownloadFileMetadataReader.cs @@ -0,0 +1,42 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Updating.Contracts; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Answers download metadata requests and counts them, which is how a test sees whether a size lookup was +/// avoided, retried, or resolved from cache rather than asked for again. +/// +internal sealed class StubDownloadFileMetadataReader : IDownloadFileMetadataReader +{ + private readonly Func>? _handler; + + public StubDownloadFileMetadataReader( + Func>? handler = null) + { + _handler = handler; + } + + /// + /// Answers every request with the same metadata, for a test whose subject is what happens after the lookup + /// rather than the lookup itself. + /// + public StubDownloadFileMetadataReader(string fileName, long? totalBytes) + : this((downloadUri, _) => Task.FromResult( + new DownloadFileMetadata(downloadUri, fileName, totalBytes))) + { + } + + public int RequestCount { get; private set; } + + public Task ReadMetadataAsync(Uri downloadUri, CancellationToken cancellationToken) + { + RequestCount++; + return _handler?.Invoke(downloadUri, cancellationToken) ?? + Task.FromException( + new InvalidOperationException($"No metadata result was configured for '{downloadUri}'.")); + } +} diff --git a/GenLauncherGO.Tests/Testing/StubFileHashService.cs b/GenLauncherGO.Tests/Testing/StubFileHashService.cs new file mode 100644 index 00000000..710b1b33 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/StubFileHashService.cs @@ -0,0 +1,26 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Updating.Contracts; + +namespace GenLauncherGO.Tests.Testing; + +internal sealed class StubFileHashService : IFileHashService +{ + /// + /// The hash a manifest fixture declares when the file on disk is meant to match it. + /// + public const string MatchingHash = "0123456789ABCDEF0123456789ABCDEF"; + + /// + /// A hash that differs from , for the corrupted-download cases. + /// + public const string MismatchedHash = "FEDCBA9876543210FEDCBA9876543210"; + + public Func HashForPath { get; init; } = _ => MatchingHash; + + public Task ComputeMd5HashAsync(string filePath, CancellationToken cancellationToken) + { + return Task.FromResult(HashForPath(filePath)); + } +} diff --git a/GenLauncherGO.Tests/Testing/StubLauncherDialogService.cs b/GenLauncherGO.Tests/Testing/StubLauncherDialogService.cs new file mode 100644 index 00000000..aeb4847d --- /dev/null +++ b/GenLauncherGO.Tests/Testing/StubLauncherDialogService.cs @@ -0,0 +1,27 @@ +using Avalonia.Controls; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Models; + +namespace GenLauncherGO.Tests.Testing; + +internal static class StubLauncherDialogService +{ + /// + /// Creates a dialog service that answers every warning confirmation with . + /// + /// + /// A substitute rather than a hand-written fake because the callers assert the request, continue text, and + /// owner window a workflow raised the dialog with, none of which + /// records. + /// + public static ILauncherDialogService AnsweringWarningConfirmations(bool confirmed) + { + ILauncherDialogService dialogService = Substitute.For(); + dialogService.ShowWarningConfirmationAsync( + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(confirmed); + return dialogService; + } +} diff --git a/GenLauncherGO.Tests/Testing/StubLauncherFilePicker.cs b/GenLauncherGO.Tests/Testing/StubLauncherFilePicker.cs new file mode 100644 index 00000000..d11ac2d0 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/StubLauncherFilePicker.cs @@ -0,0 +1,53 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Avalonia.Controls; +using GenLauncherGO.UI.Features.Launcher.Contracts; + +namespace GenLauncherGO.Tests.Testing; + +internal sealed class StubLauncherFilePicker : ILauncherFilePicker +{ + public string? GameInstallationFolderResult { get; init; } + + public IReadOnlyList ManualPackageFilesResult { get; init; } = []; + + public string? ModificationImageFileResult { get; init; } + + public string? GameExecutableFileResult { get; init; } + + public Exception? GameInstallationFolderFailure { get; init; } + + /// + /// The folder each browse started from, which is how a test sees that the picker opened where the user was + /// already working rather than at an unrelated default. + /// + public List RequestedInitialDirectories { get; } = []; + + public Task PickGameInstallationFolderAsync( + Window owner, + string? initialDirectory) + { + RequestedInitialDirectories.Add(initialDirectory); + return GameInstallationFolderFailure is null + ? Task.FromResult(GameInstallationFolderResult) + : Task.FromException(GameInstallationFolderFailure); + } + + public Task> PickManualPackageFilesAsync(Window owner) + { + return Task.FromResult(ManualPackageFilesResult); + } + + public Task PickModificationImageFileAsync( + Window owner, + string imageFilterLabel) + { + return Task.FromResult(ModificationImageFileResult); + } + + public Task PickGameExecutableFileAsync(Window owner, string gameDirectory) + { + return Task.FromResult(GameExecutableFileResult); + } +} diff --git a/GenLauncherGO.Tests/Testing/StubRemoteYamlDocumentReader.cs b/GenLauncherGO.Tests/Testing/StubRemoteYamlDocumentReader.cs new file mode 100644 index 00000000..4f144039 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/StubRemoteYamlDocumentReader.cs @@ -0,0 +1,98 @@ +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Infrastructure.Remote.Contracts; + +namespace GenLauncherGO.Tests.Testing; + +internal sealed class StubRemoteYamlDocumentReader : IRemoteYamlDocumentReader +{ + private readonly Dictionary<(Type DocumentType, Uri DocumentUri), int> _readCounts = []; + + private readonly Dictionary< + (Type DocumentType, Uri DocumentUri), + Func>> _readHandlers = []; + + private readonly Lock _sync = new(); + + public Task ReadYamlAsync(Uri documentUri, CancellationToken cancellationToken) + { + Func> handler; + int callIndex; + + lock (_sync) + { + (Type DocumentType, Uri DocumentUri) key = (typeof(T), documentUri); + callIndex = _readCounts.GetValueOrDefault(key) + 1; + _readCounts[key] = callIndex; + + if (!_readHandlers.TryGetValue(key, out handler!)) + { + throw new InvalidOperationException( + $"No YAML response was configured for {typeof(T).Name} at {documentUri}."); + } + } + + return ReadConfiguredAsync(handler, callIndex, cancellationToken); + } + + public void SetResult(Uri documentUri, T result) + { + SetHandler(documentUri, (_, _) => Task.FromResult(result)); + } + + public void SetException(Uri documentUri, Exception exception) + { + SetHandler(documentUri, (_, _) => Task.FromException(exception)); + } + + public void SetHandler( + Uri documentUri, + Func> handler) + { + ArgumentNullException.ThrowIfNull(documentUri); + ArgumentNullException.ThrowIfNull(handler); + + lock (_sync) + { + _readHandlers[(typeof(T), documentUri)] = async (callIndex, cancellationToken) => + (await handler(callIndex, cancellationToken).ConfigureAwait(false))!; + } + } + + public int GetReadCount(Uri documentUri) + { + lock (_sync) + { + return _readCounts.GetValueOrDefault((typeof(T), documentUri)); + } + } + + public int GetReadCount() + { + lock (_sync) + { + int count = 0; + + foreach (((Type DocumentType, Uri DocumentUri) key, int value) in _readCounts) + { + if (key.DocumentType == typeof(T)) + { + count += value; + } + } + + return count; + } + } + + private static async Task ReadConfiguredAsync( + Func> handler, + int callIndex, + CancellationToken cancellationToken) + { + object result = await handler(callIndex, cancellationToken).ConfigureAwait(false); + return (T)result; + } +} diff --git a/GenLauncherGO.Tests/Testing/SymbolicLinkFactAttribute.cs b/GenLauncherGO.Tests/Testing/SymbolicLinkFactAttribute.cs new file mode 100644 index 00000000..6fa05f0d --- /dev/null +++ b/GenLauncherGO.Tests/Testing/SymbolicLinkFactAttribute.cs @@ -0,0 +1,18 @@ +using System.Runtime.CompilerServices; + +namespace GenLauncherGO.Tests.Testing; + +public sealed class SymbolicLinkFactAttribute : FactAttribute +{ + public SymbolicLinkFactAttribute( + [CallerFilePath] string? sourceFilePath = null, + [CallerLineNumber] int sourceLineNumber = -1) + : base(sourceFilePath, sourceLineNumber) + { + if (!SymbolicLinkTestSupport.IsRequired && + !SymbolicLinkTestSupport.IsSupported) + { + Skip = SymbolicLinkTestSupport.UnsupportedReason; + } + } +} diff --git a/GenLauncherGO.Tests/Testing/SymbolicLinkTestSupport.cs b/GenLauncherGO.Tests/Testing/SymbolicLinkTestSupport.cs new file mode 100644 index 00000000..4ccc38ff --- /dev/null +++ b/GenLauncherGO.Tests/Testing/SymbolicLinkTestSupport.cs @@ -0,0 +1,102 @@ +using System; +using System.IO; + +namespace GenLauncherGO.Tests.Testing; + +internal static class SymbolicLinkTestSupport +{ + private const string RequiredEnvironmentVariable = "GENLAUNCHERGO_REQUIRE_SYMBOLIC_LINK_TESTS"; + + internal const string UnsupportedReason = + "These safety tests need a file that is itself a reparse point, which only a symbolic link provides. " + + "A junction is a directory, so File.Exists short-circuits before production reaches its reparse check, " + + "and every test that can use one already does. Enable Windows Developer Mode or run with symbolic-link " + + "privileges to cover the remaining cases."; + + private static readonly Lazy _symbolicLinkSupport = new(ProbeSymbolicLinkSupport); + + internal static bool IsRequired => + bool.TryParse( + Environment.GetEnvironmentVariable(RequiredEnvironmentVariable), + out bool isRequired) && + isRequired; + + internal static bool IsSupported => _symbolicLinkSupport.Value; + + public static void CreateFileLink(string linkPath, string targetPath) + { + CreateLink( + () => File.CreateSymbolicLink(linkPath, targetPath), + "file"); + } + + private static void CreateLink(Action createLink, string linkKind) + { + try + { + createLink(); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + throw new InvalidOperationException( + $"Could not create the {linkKind} symbolic link required by this safety test. " + + "CI requires symbolic-link tests to execute.", + exception); + } + } + + private static bool ProbeSymbolicLinkSupport() + { + string testRoot = Path.Combine( + Path.GetTempPath(), + "GenLauncherGO.Tests", + $"SymbolicLinkProbe-{Guid.NewGuid():N}"); + string fileTarget = Path.Combine(testRoot, "FileTarget.txt"); + string fileLink = Path.Combine(testRoot, "FileLink.txt"); + + try + { + Directory.CreateDirectory(testRoot); + File.WriteAllText(fileTarget, "target"); + File.CreateSymbolicLink(fileLink, fileTarget); + + return File.GetAttributes(fileLink).HasFlag(FileAttributes.ReparsePoint); + } + catch (Exception exception) when ( + exception is IOException or UnauthorizedAccessException or PlatformNotSupportedException) + { + return false; + } + finally + { + TryDeleteFileLink(fileLink); + TryDeleteProbeRoot(testRoot); + } + } + + private static void TryDeleteFileLink(string fileLink) + { + try + { + File.Delete(fileLink); + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + } + } + + private static void TryDeleteProbeRoot(string testRoot) + { + try + { + if (Directory.Exists(testRoot)) + { + Directory.Delete(testRoot, true); + } + } + catch (Exception exception) when (exception is IOException or UnauthorizedAccessException) + { + } + } +} diff --git a/GenLauncherGO.Tests/Testing/TestDirectory.cs b/GenLauncherGO.Tests/Testing/TestDirectory.cs new file mode 100644 index 00000000..22a3149d --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestDirectory.cs @@ -0,0 +1,114 @@ +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 +{ + private const string TestRootFolderName = "GenLauncherGO.Tests"; + + public TestDirectory() + { + Path = System.IO.Path.Combine( + System.IO.Path.GetTempPath(), + TestRootFolderName, + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public string Path { get; } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + DeleteTree(Path); + } + } + + /// + /// Deletes a tree that may contain reparse points. throws + /// when it encounters a junction, so links are removed explicitly + /// and never followed into their target. + /// + private static void DeleteTree(string path) + { + foreach (string entryPath in Directory.EnumerateFileSystemEntries(path)) + { + FileAttributes attributes = File.GetAttributes(entryPath); + bool isDirectory = (attributes & FileAttributes.Directory) != 0; + + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + if (isDirectory) + { + Directory.Delete(entryPath); + } + else + { + File.Delete(entryPath); + } + + continue; + } + + if (isDirectory) + { + DeleteTree(entryPath); + continue; + } + + File.Delete(entryPath); + } + + Directory.Delete(path); + } + + public string GetPath(string relativePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(relativePath); + + if (System.IO.Path.IsPathFullyQualified(relativePath)) + { + throw new ArgumentException("The test path must be relative.", nameof(relativePath)); + } + + string fullPath = System.IO.Path.GetFullPath(System.IO.Path.Combine(Path, relativePath)); + string resolvedRelativePath = System.IO.Path.GetRelativePath(Path, fullPath); + string parentPrefix = $"..{System.IO.Path.DirectorySeparatorChar}"; + + if (resolvedRelativePath.Equals("..", StringComparison.Ordinal) || + resolvedRelativePath.StartsWith(parentPrefix, StringComparison.Ordinal) || + System.IO.Path.IsPathFullyQualified(resolvedRelativePath)) + { + throw new ArgumentException("The test path must stay inside the owned directory.", nameof(relativePath)); + } + + return fullPath; + } + + public string CreateDirectory(string relativePath) + { + string directoryPath = GetPath(relativePath); + Directory.CreateDirectory(directoryPath); + return directoryPath; + } + + public string CreateFile(string relativePath, string contents = "") + { + ArgumentNullException.ThrowIfNull(contents); + + string filePath = GetPath(relativePath); + string? parentDirectory = System.IO.Path.GetDirectoryName(filePath); + if (parentDirectory != null) + { + Directory.CreateDirectory(parentDirectory); + } + + File.WriteAllText(filePath, contents); + return filePath; + } +} diff --git a/GenLauncherGO.Tests/Testing/TestImageFile.cs b/GenLauncherGO.Tests/Testing/TestImageFile.cs new file mode 100644 index 00000000..6ce5f6f7 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestImageFile.cs @@ -0,0 +1,28 @@ +using System; +using System.IO; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Supplies the smallest real image the launcher's bitmap loading accepts. +/// +internal static class TestImageFile +{ + private const string OnePixelPngBase64 = + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII="; + + /// + /// A one-pixel PNG, so a test asserting on decoded pixel size has a known answer. + /// + private static byte[] OnePixelPng { get; } = Convert.FromBase64String(OnePixelPngBase64); + + public static string Write(TestDirectory directory, string fileName) + { + ArgumentNullException.ThrowIfNull(directory); + + string filePath = directory.GetPath(fileName); + Directory.CreateDirectory(Path.GetDirectoryName(filePath)!); + File.WriteAllBytes(filePath, OnePixelPng); + return filePath; + } +} diff --git a/GenLauncherGO.Tests/Testing/TestLaunchContentIntegrityCoordinator.cs b/GenLauncherGO.Tests/Testing/TestLaunchContentIntegrityCoordinator.cs new file mode 100644 index 00000000..bf31f5fa --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestLaunchContentIntegrityCoordinator.cs @@ -0,0 +1,51 @@ +using System; +using System.Threading; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Shared.Localization; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Builds the integrity coordinator with a resolution service that reports nothing to fix, which is the +/// arrangement every test that is not about integrity needs. +/// +internal static class TestLaunchContentIntegrityCoordinator +{ + public static LaunchContentIntegrityCoordinator Create( + ILaunchContentIntegrityResolutionService? resolutionService = null, + ILauncherContentCatalog? catalog = null, + LauncherRuntimePathContext? runtimePaths = null, + LauncherPackageActivityService? packageActivityService = null, + ILauncherDialogService? dialogService = null, + ILauncherStringLocalizer? stringLocalizer = null) + { + return new LaunchContentIntegrityCoordinator( + resolutionService ?? CreateNoIssueResolutionService(), + catalog ?? new FakeLauncherContentCatalog(), + runtimePaths ?? TestLauncherPaths.CreateRuntimePathContext(TestLauncherPaths.Create()), + packageActivityService ?? new LauncherPackageActivityService(), + stringLocalizer ?? FakeStringLocalizer.Create(TestLocalizedStrings.Integrity), + dialogService ?? Substitute.For(), + NullLogger.Instance); + } + + private static ILaunchContentIntegrityResolutionService CreateNoIssueResolutionService() + { + ILaunchContentIntegrityResolutionService resolutionService = + Substitute.For(); + resolutionService.VerifyAsync( + Arg.Any(), + Arg.Any()) + .Returns(new LaunchContentIntegrityVerificationResult( + new ContentIntegrityReport(Array.Empty()), + Array.Empty())); + return resolutionService; + } +} diff --git a/GenLauncherGO.Tests/Testing/TestLauncherContent.cs b/GenLauncherGO.Tests/Testing/TestLauncherContent.cs new file mode 100644 index 00000000..6594b505 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestLauncherContent.cs @@ -0,0 +1,188 @@ +using System; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Builds launcher content fixtures. This is the single place a test describes a version, so a change to the +/// domain model lands once instead of in every test file that used to spell out its own factory. +/// +internal static class TestLauncherContent +{ + /// + /// The S3 endpoint every managed-package fixture publishes from. + /// + public const string S3Host = "https://s3.example.test"; + + /// + /// The bucket every managed-package fixture publishes into. + /// + public const string S3Bucket = "mods"; + + public static LauncherContentVersion Version( + string name = "ShockWave", + string version = "1.0", + ModificationType type = ModificationType.Mod, + string parentContentName = "", + bool installed = false, + bool isSelected = false, + ContentSourceKind sourceKind = ContentSourceKind.UnknownLegacy, + string simpleDownloadLink = "", + bool deprecated = false, + bool downloadSuspended = false, + double suspendedProgressPercentage = 0, + LauncherContentTheme? theme = null) + { + return new LauncherContentVersion(new LauncherContentInstallation + { + Installed = installed, + IsSelected = isSelected, + ContentSourceKind = sourceKind, + DownloadSuspended = downloadSuspended, + SuspendedProgressPercentage = suspendedProgressPercentage + }) + { + ModificationType = type, + Name = name, + Version = version, + ParentContentName = parentContentName, + SimpleDownloadLink = simpleDownloadLink, + Deprecated = deprecated, + Theme = theme + }; + } + + /// + /// Builds a version whose metadata resolves to . + /// + public static LauncherContentVersion S3Version( + string name = "ShockWave", + string version = "1.0", + ModificationType type = ModificationType.Mod, + string parentContentName = "", + bool installed = false, + bool isSelected = false, + string? s3FolderName = null) + { + return new LauncherContentVersion(new LauncherContentInstallation + { + Installed = installed, + IsSelected = isSelected, + ContentSourceKind = ContentSourceKind.ManagedS3 + }) + { + ModificationType = type, + Name = name, + Version = version, + ParentContentName = parentContentName, + S3HostLink = S3Host, + S3BucketName = S3Bucket, + S3FolderName = s3FolderName ?? $"{name}/{version}" + }; + } + + /// + /// Materializes a content card through a real round trip, so the card a test + /// asserts against was assembled by the production merge policy rather than by the test. + /// + public static LauncherContent From(params LauncherContentVersion[] versions) + { + ArgumentNullException.ThrowIfNull(versions); + + if (versions.Length == 0) + { + throw new ArgumentException("At least one version is required.", nameof(versions)); + } + + var data = new LauncherData(); + foreach (LauncherContentVersion version in versions) + { + data.AddOrUpdate(version); + } + + return data.FindContent(versions[0].ContentKey) ?? + throw new InvalidOperationException( + $"'{versions[0].DisplayName}' is not a content kind LauncherData stores."); + } + + public static CatalogBuilder Catalog() + { + return new CatalogBuilder(); + } + + /// + /// Fills a through the production catalog model. + /// + internal sealed class CatalogBuilder + { + private readonly FakeLauncherContentCatalog _catalog = new(); + + public CatalogBuilder WithMod( + string name, + string version = "1.0", + bool installed = true) + { + return Add(Version(name, version, ModificationType.Mod, installed: installed)); + } + + public CatalogBuilder WithPatch( + string parentContentName, + string name, + string version = "1.0", + bool installed = true) + { + return Add(Version( + name, + version, + ModificationType.Patch, + parentContentName, + installed)); + } + + public CatalogBuilder WithAddon( + string parentContentName, + string name, + string version = "1.0", + bool installed = true) + { + return Add(Version( + name, + version, + ModificationType.Addon, + parentContentName, + installed)); + } + + /// + /// Marks an already-added card and its versions as the user's selection. + /// + public CatalogBuilder Selected( + string name, + ModificationType type = ModificationType.Mod, + string parentContentName = "") + { + LauncherContent content = + _catalog.Data.FindContent(new LauncherContentKey(type, parentContentName, name, string.Empty)) ?? + throw new InvalidOperationException($"'{name}' was not added to the catalog."); + content.IsSelected = true; + foreach (LauncherContentVersion version in content.Versions) + { + version.Installation.IsSelected = true; + } + + return this; + } + + public FakeLauncherContentCatalog Build() + { + return _catalog; + } + + private CatalogBuilder Add(LauncherContentVersion version) + { + _catalog.Data.AddOrUpdate(version); + return this; + } + } +} diff --git a/GenLauncherGO.Tests/Testing/TestLauncherInstallations.cs b/GenLauncherGO.Tests/Testing/TestLauncherInstallations.cs new file mode 100644 index 00000000..823818c6 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestLauncherInstallations.cs @@ -0,0 +1,36 @@ +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Core.Startup.Contracts; +using GenLauncherGO.UI.Features.Launcher.Contracts; +using GenLauncherGO.UI.Features.Startup.ViewModels; +using GenLauncherGO.UI.Shared.Localization; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Builds the shared installation-path view model that first-run setup and launcher settings both edit. +/// +internal static class TestLauncherInstallations +{ + /// + /// The launcher root every installation test validates against. + /// + public static LauncherStoragePaths StoragePaths { get; } = new(@"C:\Launcher"); + + public static LauncherInstallationsViewModel CreateViewModel( + LauncherInstallations? installations = null, + IGameInstallationService? installationService = null, + ILauncherFilePicker? filePicker = null, + ILauncherHostEnvironmentService? hostEnvironmentService = null, + LauncherStoragePaths? storagePaths = null, + ILauncherStringLocalizer? stringLocalizer = null) + { + return new LauncherInstallationsViewModel( + installations ?? new LauncherInstallations(), + storagePaths ?? StoragePaths, + installationService ?? new FakeGameInstallationService(), + hostEnvironmentService ?? Substitute.For(), + filePicker ?? new StubLauncherFilePicker(), + stringLocalizer ?? new FakeStringLocalizer()); + } +} diff --git a/GenLauncherGO.Tests/Testing/TestLauncherLaunchCoordinator.cs b/GenLauncherGO.Tests/Testing/TestLauncherLaunchCoordinator.cs new file mode 100644 index 00000000..fbf98503 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestLauncherLaunchCoordinator.cs @@ -0,0 +1,87 @@ +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.Settings.Contracts; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Shared.Localization; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Testing; + +internal static class TestLauncherLaunchCoordinator +{ + public static LauncherLaunchCoordinator Create( + LauncherPackageActivityService? packageActivityService = null, + ILauncherPreferencesService? preferencesService = null, + ILauncherContentCatalog? catalog = null, + ILauncherStringLocalizer? stringLocalizer = null, + ILaunchPreparationService? preparationService = null, + IGameProcessLauncher? processLauncher = null, + ILauncherDialogService? dialogService = null, + ILaunchContentIntegrityResolutionService? integrityResolutionService = null, + LauncherRuntimePathContext? runtimePaths = null) + { + LauncherPackageActivityService resolvedPackageActivityService = + packageActivityService ?? new LauncherPackageActivityService(); + ILauncherPreferencesService resolvedPreferencesService = + preferencesService ?? new RecordingLauncherPreferencesService(new LauncherPreferences()); + ILauncherStringLocalizer resolvedStringLocalizer = + stringLocalizer ?? FakeStringLocalizer.Create(TestLocalizedStrings.Launch); + ILauncherDialogService resolvedDialogService = + dialogService ?? Substitute.For(); + ILaunchPreparationService resolvedPreparationService = + preparationService ?? CreateSuccessfulPreparationService(); + IGameProcessLauncher resolvedProcessLauncher = + processLauncher ?? CreateSuccessfulProcessLauncher(); + LauncherRuntimePathContext resolvedRuntimePaths = + runtimePaths ?? TestLauncherPaths.CreateRuntimePathContext(TestLauncherPaths.Create()); + + return new LauncherLaunchCoordinator( + resolvedPreferencesService, + resolvedPreparationService, + resolvedProcessLauncher, + TestLaunchContentIntegrityCoordinator.Create( + integrityResolutionService, + catalog, + resolvedRuntimePaths, + resolvedPackageActivityService, + resolvedDialogService, + resolvedStringLocalizer), + resolvedPackageActivityService, + resolvedRuntimePaths, + resolvedStringLocalizer, + resolvedDialogService, + NullLogger.Instance); + } + + private static ILaunchPreparationService CreateSuccessfulPreparationService() + { + ILaunchPreparationService preparationService = Substitute.For(); + preparationService.Prepare( + Arg.Any(), + Arg.Any()) + .Returns(true); + preparationService.Cleanup( + Arg.Any(), + Arg.Any()) + .Returns(true); + return preparationService; + } + + private static IGameProcessLauncher CreateSuccessfulProcessLauncher() + { + IGameProcessLauncher processLauncher = Substitute.For(); + processLauncher.StartAsync( + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult( + new CompletedGameProcessLaunchOperation(true, "generals.exe"))); + return processLauncher; + } +} diff --git a/GenLauncherGO.Tests/Testing/TestLauncherPaths.cs b/GenLauncherGO.Tests/Testing/TestLauncherPaths.cs new file mode 100644 index 00000000..52f7638e --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestLauncherPaths.cs @@ -0,0 +1,96 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Startup; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Builds the launcher path sets tests need through the production authority, +/// so no test restates the owned folder layout. +/// +internal static class TestLauncherPaths +{ + private const string DefaultGameDirectory = @"C:\Games\ZeroHour"; + + private const string VirtualRootFolderName = "GenLauncherGO.Tests"; + + public static LauncherPaths Create( + string gameDirectory = DefaultGameDirectory, + SupportedGame game = SupportedGame.ZeroHour) + { + string fullGameDirectory = Path.GetFullPath(gameDirectory); + string gameParentDirectory = Path.GetDirectoryName(fullGameDirectory) + ?? throw new ArgumentException("The test game directory must have a parent.", + nameof(gameDirectory)); + string executableDirectory = Path.Combine( + gameParentDirectory, + Path.GetFileName(fullGameDirectory) + "-Launcher"); + return new LauncherStoragePaths(executableDirectory).CreateGamePaths(game, gameDirectory); + } + + public static LauncherPaths Create(TestDirectory directory, SupportedGame game = SupportedGame.ZeroHour) + { + ArgumentNullException.ThrowIfNull(directory); + + string gameDirectory = directory.CreateDirectory("Game"); + string executableDirectory = directory.CreateDirectory("Launcher"); + return CreateOwnedDirectories( + new LauncherStoragePaths(executableDirectory).CreateGamePaths(game, gameDirectory)); + } + + /// + /// Builds a path set below the test working directory without creating anything on disk, for tests that only + /// assert how paths are composed. + /// + public static LauncherPaths CreateVirtualRoot(string name, SupportedGame game = SupportedGame.ZeroHour) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + + string root = Path.GetFullPath(Path.Combine(VirtualRootFolderName, name)); + return new LauncherStoragePaths(Path.Combine(root, "Launcher")) + .CreateGamePaths(game, Path.Combine(root, "Game")); + } + + /// + /// Builds both supported games from one storage root, which is the only arrangement a game switch is valid in. + /// + public static (LauncherRuntimePathContext RuntimePaths, LauncherPaths Generals, LauncherPaths ZeroHour) + CreateTwoGameRuntime(TestDirectory directory) + { + ArgumentNullException.ThrowIfNull(directory); + + var storagePaths = new LauncherStoragePaths(directory.CreateDirectory("Launcher")); + LauncherPaths generalsPaths = CreateOwnedDirectories(storagePaths.CreateGamePaths( + SupportedGame.Generals, + directory.CreateDirectory("Generals"))); + LauncherPaths zeroHourPaths = CreateOwnedDirectories(storagePaths.CreateGamePaths( + SupportedGame.ZeroHour, + directory.CreateDirectory("ZeroHour"))); + + return (new LauncherRuntimePathContext(storagePaths, zeroHourPaths), generalsPaths, zeroHourPaths); + } + + public static LauncherRuntimePathContext CreateRuntimePathContext(LauncherPaths paths) + { + ArgumentNullException.ThrowIfNull(paths); + + string dataDirectory = Path.GetDirectoryName(paths.OwnedGameDataDirectory) + ?? throw new ArgumentException("The owned game data directory must have a parent.", + nameof(paths)); + string executableDirectory = Path.GetDirectoryName(dataDirectory) + ?? throw new ArgumentException( + "The shared launcher data directory must have a parent.", nameof(paths)); + return new LauncherRuntimePathContext(new LauncherStoragePaths(executableDirectory), paths); + } + + private static LauncherPaths CreateOwnedDirectories(LauncherPaths paths) + { + Directory.CreateDirectory(paths.OwnedGameDataDirectory); + Directory.CreateDirectory(paths.ImagesDirectory); + Directory.CreateDirectory(paths.ModsDirectory); + Directory.CreateDirectory(paths.TempDirectory); + Directory.CreateDirectory(paths.DeploymentDirectory); + Directory.CreateDirectory(paths.StateDirectory); + return paths; + } +} diff --git a/GenLauncherGO.Tests/Testing/TestLauncherRuntimeContext.cs b/GenLauncherGO.Tests/Testing/TestLauncherRuntimeContext.cs new file mode 100644 index 00000000..7f70c56b --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestLauncherRuntimeContext.cs @@ -0,0 +1,38 @@ +using GenLauncherGO.Core.Startup; +using GenLauncherGO.UI.Features.Startup; +using GenLauncherGO.UI.Shared.Themes; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Builds the runtime context on the single launcher layout owns. +/// +internal static class TestLauncherRuntimeContext +{ + internal const string LauncherVersion = "1.0.0-test"; + + public static LauncherRuntimeContext Create( + SupportedGame currentlyManagedGame = SupportedGame.ZeroHour, + ColorsInfo? colors = null, + bool connected = false) + { + return Create( + TestLauncherPaths.Create(game: currentlyManagedGame), + LauncherVersion, + colors, + connected); + } + + public static LauncherRuntimeContext Create( + LauncherPaths paths, + string version = LauncherVersion, + ColorsInfo? colors = null, + bool connected = false) + { + return new LauncherRuntimeContext(TestLauncherPaths.CreateRuntimePathContext(paths), version) + { + Colors = colors ?? TestLauncherTheme.Create(), + Connected = connected + }; + } +} diff --git a/GenLauncherGO.Tests/Testing/TestLauncherTheme.cs b/GenLauncherGO.Tests/Testing/TestLauncherTheme.cs new file mode 100644 index 00000000..334624a7 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestLauncherTheme.cs @@ -0,0 +1,38 @@ +using Avalonia.Media; +using GenLauncherGO.UI.Shared.Themes; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Builds a launcher theme for tests from the shipped Zero Hour palette. +/// +/// +/// Values are taken from the real preset rather than restated, so a test can never assert against a palette the +/// product does not actually produce. Override a slot only when a test needs to distinguish that one colour. +/// +internal static class TestLauncherTheme +{ + public static ColorsInfo Create(IImageBrush? backgroundImage = null, string border = "#00E3FF") + { + return new ColorsInfo( + borderColor: border, + inactiveBorderColor: "DarkGray", + inactiveBorder2: "#7A7DB0", + activeColor: "#BAFF0C", + darkFillColor: "#232977", + darkBackgroundColor: "#090502", + lightBackgroundColor: "#B3000000", + defaultTextColor: "White", + downloadTextColor: "#090502", + selectionStartColor: "#F21D2057", + selectionMiddleColor: "#F21D2057", + buttonSelectionColor: "#2534FF", + actionTextColor: "White", + headingTextColor: "White", + errorColor: "Red", + disabledTextColor: "#FF888888", + chromeBackgroundColor: "#FF000000", + scrimColor: "#66000000", + backgroundImage: backgroundImage); + } +} diff --git a/GenLauncherGO.Tests/Testing/TestLocalizedStrings.cs b/GenLauncherGO.Tests/Testing/TestLocalizedStrings.cs new file mode 100644 index 00000000..c1adbe8b --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestLocalizedStrings.cs @@ -0,0 +1,185 @@ +using System; +using System.Collections.Generic; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// The localized text tests assert against, grouped by the feature that needs it. +/// +/// +/// Each key carries one value across every set, so two tests can never assert different text for the same +/// resource. includes and includes +/// , because launching runs integrity review and the launcher window runs both. +/// stands alone: it is the executable manager's vocabulary, which shares only display +/// names with the rest. +/// +internal static class TestLocalizedStrings +{ + public static IReadOnlyDictionary Integrity { get; } = + new Dictionary(StringComparer.Ordinal) + { + ["AbsorbAndLaunch"] = "Absorb and launch", + ["CancelLaunch"] = "Cancel launch", + ["DownloadInProgress"] = "Downloaded {0} of {1}", + ["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}", + ["IntegrityRedownloadGroup"] = "Redownload", + ["IntegrityRepairGroup"] = "Repair", + ["IntegrityRepeatedFailure"] = "Repeated failure", + ["IntegrityTrustGroup"] = "Trust", + ["LaunchVerificationRunning"] = "Verification running", + ["Preparing"] = "Preparing", + ["TrustAsManual"] = "Trust as manual", + ["UnpackingPreparing"] = "Unpacking" + }; + + public static IReadOnlyDictionary Launch { get; } = Merge( + Integrity, + new Dictionary(StringComparer.Ordinal) + { + ["AdvertisingCannotLaunch"] = "Advertisements cannot be launched.", + ["AdvertisingDonationAlerts"] = "Donate", + ["Cancel"] = "Cancel", + ["CancelDownloadAction"] = "Cancel Download", + ["Canceled"] = "Canceled", + ["Compatibility"] = "Compatibility", + ["Delete"] = "Delete", + ["DeploymentRecoveryFailed"] = "Deployment recovery failed.", + ["Deprecated"] = "{0} is deprecated", + ["Error"] = "Error: ", + ["FilesCorrupted"] = "Files corrupted", + ["GameRunning"] = "Game running", + ["GameSwitchBlockedDetails"] = "Wait for active work to finish.", + ["GameSwitchBlockedTitle"] = "Game switch blocked", + ["GameSwitchFailedDetails"] = "Could not switch: {0}", + ["GameSwitchFailedTitle"] = "Game switch failed", + ["Install"] = "Install", + ["InstallInProgress"] = "{0} install running", + ["InstallationPathUnavailable"] = "The installation path is unavailable.", + ["LaunchAborted"] = "Launch aborted", + ["LaunchCloseBlockedDetails"] = "Wait for launch.", + ["LaunchCloseBlockedTitle"] = "Launch in progress", + ["LatestVersion"] = "Latest version: ", + ["ModificationsWithUpdate"] = "Updates available", + ["NotInstalled"] = "{0} is not installed", + ["PackageActivityInProgress"] = "Package activity", + ["PackageActivityInProgressDetails"] = "Package activity details", + ["Pause"] = "Pause", + ["SettingsSaveFailedDetails"] = "Preferences could not be saved.", + ["Reinstall"] = "Reinstall", + ["RemoveFromList"] = "Remove from list", + ["Resume"] = "Resume", + ["UnexpectedErrorDetails"] = "Try again", + ["UnexpectedErrorTitle"] = "Unexpected error", + ["UninstalledUpdate"] = "{0} update is not installed", + ["Update"] = "Update", + ["UpToDate"] = "Up to date", + ["WorldBuilderRunning"] = "World Builder running" + }); + + public static IReadOnlyDictionary Launcher { get; } = Merge( + Launch, + new Dictionary(StringComparer.Ordinal) + { + ["AddAddonFromFiles"] = "Add addon for {0}", + ["AddPatchFromFiles"] = "Add patch for {0}", + ["Addons"] = "Add-ons for ", + ["CancelDownload"] = "Cancel download", + ["CancelDownloadDetails"] = "Cancel {0} and delete content downloaded so far", + ["ChangeToFullScreen"] = "Change to full screen", + ["ChangeToNormalStart"] = "Change to normal start", + ["ChangeToQuickStart"] = "Change to quick start", + ["ChangeToWindowed"] = "Change to windowed", + ["CloseAnyway"] = "Close anyway", + ["ClosePackageActivityDetails"] = "Close {0}?", + ["CommunityGameClientDisplayName"] = "TheSuperHackers", + ["CurrentVersion"] = "Current version: ", + ["Discord"] = "Discord", + ["ExecutableUnavailable"] = "Executable unavailable", + ["FinishProcess"] = "Finish process", + ["ForceQuitRunningProcess"] = "Force quit", + ["ForceQuitRunningProcessConfirmationDetails"] = "Force quit {0}?", + ["ForceQuitRunningProcessConfirmationTitle"] = "Force quit?", + ["GameIsStillRunning"] = "Game running", + ["GeneralsOnlineGameClientDisplayName"] = "GeneralsOnline", + ["GeneralsShortName"] = "Generals", + ["GenPatcherRecommendationMessage"] = + "It is strongly recommended to use GenPatcher when playing the retail game.", + ["GenPatcherRecommendationTitle"] = "GenPatcher recommendation", + ["Image"] = "Image", + ["ModDb"] = "Mod DB", + ["Patches"] = "Patches for ", + ["Remove"] = "Remove", + ["RemoveContent"] = "Remove content?", + ["RemoveContentDetails"] = "Remove {0}", + ["RemoveFromListConfirmation"] = "Remove from list?", + ["RemoveFromListDetails"] = "Remove {0} from list", + ["RestartBlockedActiveOperation"] = "Finish {0} before restarting.", + ["RestartBlockedTitle"] = "Restart unavailable", + ["RetailGameClientDisplayName"] = "Retail", + ["RetailWorldBuilder"] = "Retail", + ["RunningProcessCloseBlockedDetails"] = "Close process first", + ["RunningProcessCloseBlockedTitle"] = "Process running", + ["RunningProcessStatus"] = "Running {0}", + ["RunningProcessUnknown"] = "Unknown process", + ["SetImage"] = "Set image", + ["SuperHackersWorldBuilder"] = "TheSuperHackers", + ["ThankYou"] = "Thank you", + ["VisitGenPatcherDownloadPage"] = "Visit GenPatcher download page", + ["Yes"] = "Yes", + ["ZeroHourShortName"] = "Zero Hour" + }); + + public static IReadOnlyDictionary Settings { get; } = + new Dictionary(StringComparer.Ordinal) + { + ["AddExecutable"] = "Add executable", + ["BuiltInExecutables"] = "Built-in executables", + ["CommunityGameClientDisplayName"] = "TheSuperHackers", + ["CustomExecutables"] = "Custom executables", + ["EditExecutable"] = "Edit executable", + ["ExecutableDetailsRequired"] = "Details required", + ["ExecutableFileAlreadyExists"] = "Duplicate file", + ["ExecutableMustBeInGameRoot"] = "Must be in game root", + ["ExecutableNameAlreadyExists"] = "Duplicate name", + ["ExecutableUnavailable"] = "Unavailable", + ["GeneralsOnlineGameClientDisplayName"] = "GeneralsOnline", + ["ManageGameClients"] = "Manage game clients", + ["ManageWorldBuilders"] = "Manage World Builders", + ["RetailGameClientDisplayName"] = "Retail", + ["RetailWorldBuilder"] = "Retail", + ["SuperHackersWorldBuilder"] = "TheSuperHackers" + }; + + private static IReadOnlyDictionary Merge( + IReadOnlyDictionary baseSet, + Dictionary additions) + { + var merged = new Dictionary(baseSet, StringComparer.Ordinal); + foreach ((string key, string value) in additions) + { + merged[key] = value; + } + + return merged; + } +} diff --git a/GenLauncherGO.Tests/Testing/TestMainWindowViewModel.cs b/GenLauncherGO.Tests/Testing/TestMainWindowViewModel.cs new file mode 100644 index 00000000..489df7a6 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestMainWindowViewModel.cs @@ -0,0 +1,59 @@ +using GenLauncherGO.Core.Launching.Contracts; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Settings.Contracts; +using GenLauncherGO.Core.Settings.Models; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Launcher.Services; +using GenLauncherGO.UI.Features.Launcher.ViewModels; +using GenLauncherGO.UI.Features.Mods; +using GenLauncherGO.UI.Features.Startup; +using GenLauncherGO.UI.Shared.Localization; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Owns the eleven collaborators the main window view model takes, so a test names only the ones it asserts on. +/// +internal static class TestMainWindowViewModel +{ + public static MainWindowViewModel Create( + ILauncherContentCatalog? catalog = null, + ILauncherPreferencesService? preferencesService = null, + LauncherRuntimeContext? runtimeContext = null, + IGameExecutableDiscoveryService? executableDiscovery = null, + LauncherPackageActivityService? packageActivityService = null, + LauncherLaunchCoordinator? launchCoordinator = null, + ILauncherStringLocalizer? stringLocalizer = null) + { + ILauncherContentCatalog resolvedCatalog = catalog ?? new FakeLauncherContentCatalog(); + ILauncherPreferencesService resolvedPreferencesService = + preferencesService ?? new RecordingLauncherPreferencesService(new LauncherPreferences()); + LauncherRuntimeContext resolvedRuntimeContext = runtimeContext ?? TestLauncherRuntimeContext.Create(); + ILauncherStringLocalizer resolvedStringLocalizer = + stringLocalizer ?? FakeStringLocalizer.Create(TestLocalizedStrings.Launcher); + LauncherPackageActivityService resolvedPackageActivityService = + packageActivityService ?? new LauncherPackageActivityService(); + + return new MainWindowViewModel( + resolvedPreferencesService, + new LauncherExecutableSelectionService( + executableDiscovery ?? Substitute.For(), + resolvedRuntimeContext, + resolvedPreferencesService, + resolvedStringLocalizer), + resolvedCatalog, + resolvedRuntimeContext, + resolvedStringLocalizer, + new ModificationImageSourceFactory(NullLogger.Instance), + Substitute.For(), + resolvedPackageActivityService, + NullLogger.Instance, + launchCoordinator ?? TestLauncherLaunchCoordinator.Create( + resolvedPackageActivityService, + resolvedPreferencesService, + resolvedCatalog, + resolvedStringLocalizer), + NullLogger.Instance); + } +} diff --git a/GenLauncherGO.Tests/Testing/TestModificationTile.cs b/GenLauncherGO.Tests/Testing/TestModificationTile.cs new file mode 100644 index 00000000..4b4c5f96 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestModificationTile.cs @@ -0,0 +1,32 @@ +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Mods; +using GenLauncherGO.UI.Shared.Localization; +using GenLauncherGO.UI.Shared.Themes; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Owns the collaborators a modification tile needs but no test is asserting about. +/// +internal static class TestModificationTile +{ + public static ModificationViewModel Create( + LauncherContent content, + ILauncherStringLocalizer? localizer = null, + LauncherPackageActivityService? activity = null, + ColorsInfo? colors = null, + IModificationImageFileService? imageFileService = null) + { + return new ModificationViewModel( + content, + new ModificationImageSourceFactory(NullLogger.Instance), + TestLauncherRuntimeContext.Create(colors: colors), + imageFileService ?? Substitute.For(), + localizer ?? new FakeStringLocalizer(), + activity ?? new LauncherPackageActivityService(), + NullLogger.Instance); + } +} diff --git a/GenLauncherGO.Tests/Testing/TestPackageDownload.cs b/GenLauncherGO.Tests/Testing/TestPackageDownload.cs new file mode 100644 index 00000000..7e189b9e --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestPackageDownload.cs @@ -0,0 +1,57 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.UI.Features.Integrity; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Starts package downloads that hold a chosen state, so a test can drive the rest of the launcher around one. +/// +internal static class TestPackageDownload +{ + /// + /// Starts a transfer that only ends when its token is canceled and leaves it paused, matching a user who + /// paused a download and then went on to do something else with the launcher. + /// + public static Task StartPaused( + LauncherPackageActivityService packageActivityService) + { + ArgumentNullException.ThrowIfNull(packageActivityService); + + object owner = new(); + packageActivityService.TryStartDownload( + owner, + "Paused download", + (_, _, cancellationToken) => WaitForCancellationAsync(cancellationToken), + () => { }, + _ => { }, + () => { }, + _ => { }, + out Task? lifecycle) + .Should() + .BeTrue(); + packageActivityService.TryToggleDownloadPause(owner, out bool paused).Should().BeTrue(); + paused.Should().BeTrue(); + + return lifecycle ?? throw new InvalidOperationException("Download lifecycle task was not created."); + } + + /// + /// Mirrors a transport that reports a canceled transfer as a terminal result rather than as a fault. + /// + public static async Task WaitForCancellationAsync(CancellationToken cancellationToken) + { + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + } + catch (OperationCanceledException) + { + return PackageDownloadResult.Canceled(); + } + + throw new InvalidOperationException("The cancellation delay unexpectedly completed."); + } +} diff --git a/GenLauncherGO.Tests/Testing/TestPackageUpdatePaths.cs b/GenLauncherGO.Tests/Testing/TestPackageUpdatePaths.cs new file mode 100644 index 00000000..d2368cc9 --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestPackageUpdatePaths.cs @@ -0,0 +1,36 @@ +using System; +using System.IO; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Startup; +using GenLauncherGO.Infrastructure.Updating.Models; + +namespace GenLauncherGO.Tests.Testing; + +/// +/// Builds package ownership boundaries from the launcher's own directories, so no test restates where staging, +/// installed content, or recovery backups live. +/// +internal static class TestPackageUpdatePaths +{ + public static PackageUpdatePathSet Create( + LauncherPaths paths, + string temporaryRelativePath, + string installedRelativePath, + string? latestRelativePath = null) + { + ArgumentNullException.ThrowIfNull(paths); + ArgumentException.ThrowIfNullOrWhiteSpace(temporaryRelativePath); + ArgumentException.ThrowIfNullOrWhiteSpace(installedRelativePath); + + return new PackageUpdatePathSet( + Owned(paths.PackagesDirectory, temporaryRelativePath), + Owned(paths.ModsDirectory, installedRelativePath), + Owned(paths.PackageBackupsDirectory, installedRelativePath), + latestRelativePath is null ? null : Owned(paths.ModsDirectory, latestRelativePath)); + } + + private static OwnedContentPath Owned(string ownerRoot, string relativePath) + { + return new OwnedContentPath(ownerRoot, Path.Combine(ownerRoot, relativePath)); + } +} diff --git a/GenLauncherGO.Tests/Testing/TestTimeouts.cs b/GenLauncherGO.Tests/Testing/TestTimeouts.cs new file mode 100644 index 00000000..f95f117e --- /dev/null +++ b/GenLauncherGO.Tests/Testing/TestTimeouts.cs @@ -0,0 +1,15 @@ +using System; + +namespace GenLauncherGO.Tests.Testing; + +internal static class TestTimeouts +{ + /// + /// How long a test waits for asynchronous work before failing it. + /// + /// + /// Long enough to absorb scheduling jitter on a loaded agent, short enough that a genuine deadlock fails the + /// run instead of hanging it. Shared so the budget is one edit rather than one per await. + /// + public static readonly TimeSpan Wait = TimeSpan.FromSeconds(5); +} diff --git a/GenLauncherGO.Tests/UI/Features/Dialogs/Services/AvaloniaLauncherDialogServiceTests.cs b/GenLauncherGO.Tests/UI/Features/Dialogs/Services/AvaloniaLauncherDialogServiceTests.cs new file mode 100644 index 00000000..fa9abc65 --- /dev/null +++ b/GenLauncherGO.Tests/UI/Features/Dialogs/Services/AvaloniaLauncherDialogServiceTests.cs @@ -0,0 +1,308 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.ExceptionServices; +using System.Threading; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Threading; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Models; +using GenLauncherGO.UI.Features.Dialogs.Services; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Mods.Views; +using GenLauncherGO.UI.Shared.Localization; + +namespace GenLauncherGO.Tests.UI.Features.Dialogs.Services; + +[Collection("Avalonia")] +public sealed class AvaloniaLauncherDialogServiceTests +{ + [Fact] + public void ShowModificationSelectionAsync_AcceptedSelectionReturnsName() + { + StaTestRunner.Run(async () => + { + AvaloniaLauncherDialogService service = CreateService(); + + string? selectedName = await OwnedDialogTestHost.RunAsync( + dialog => dialog.ViewModel.AcceptCommand.Execute(null), + owner => service.ShowModificationSelectionAsync(new[] { "Contra" }, owner)); + + selectedName.Should().Be("Contra"); + }); + } + + [Fact] + public void ShowModificationSelectionAsync_CanceledDialog_StopsPendingMetadataWork() + { + StaTestRunner.Run(async () => + { + bool observedCancellation = false; + FakeLauncherContentCatalog catalog = new() + { + MetadataHandler = async (_, cancellationToken) => + { + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + throw new InvalidOperationException("The cancellation delay unexpectedly completed."); + } + finally + { + observedCancellation = cancellationToken.IsCancellationRequested; + } + } + }; + AvaloniaLauncherDialogService service = CreateService(catalog: catalog); + + string? selectedName = await OwnedDialogTestHost.RunAsync( + dialog => dialog.ViewModel.CancelCommand.Execute(null), + owner => service.ShowModificationSelectionAsync(new[] { "Contra" }, owner)); + + selectedName.Should().BeNull(); + observedCancellation.Should().BeTrue(); + }); + } + + [Fact] + public void ShowIntegrityReviewAsync_ConfirmedResolutionReturnsTrue() + { + StaTestRunner.Run(async () => + { + AvaloniaLauncherDialogService service = CreateService(); + + bool confirmed = await OwnedDialogTestHost.RunAsync( + dialog => dialog.ViewModel.ConfirmResolutionCommand.Execute(null), + owner => service.ShowIntegrityReviewAsync( + new ContentIntegrityReport(Array.Empty()), + owner)); + + confirmed.Should().BeTrue(); + }); + } + + [Fact] + public void ShowIntegrityReviewAsync_CanceledReviewReturnsFalse() + { + StaTestRunner.Run(async () => + { + AvaloniaLauncherDialogService service = CreateService(); + + bool confirmed = await OwnedDialogTestHost.RunAsync( + dialog => dialog.ViewModel.CancelCommand.Execute(null), + owner => service.ShowIntegrityReviewAsync( + new ContentIntegrityReport(Array.Empty()), + owner)); + + confirmed.Should().BeFalse(); + }); + } + + [Fact] + public void ShowManualModificationImportAsync_AcceptedDetailsReturnImportResult() + { + StaTestRunner.Run(async () => + { + AvaloniaLauncherDialogService service = CreateService(); + + ManualModificationDialogResult? result = + await OwnedDialogTestHost.RunAsync( + dialog => + { + dialog.ViewModel.ModificationName = "Patch Pack"; + dialog.ViewModel.Version = "1.2"; + dialog.ViewModel.AcceptCommand.Execute(null); + }, + owner => service.ShowManualModificationImportAsync( + new[] { @"C:\Packages\patch.zip" }, + owner)); + + result.Should().NotBeNull(); + result!.ModificationName.Should().Be("Patch Pack"); + result.Version.Should().Be("1.2"); + }); + } + + [Fact] + public void ShowManualModificationImportAsync_CanceledImportReturnsNull() + { + StaTestRunner.Run(async () => + { + AvaloniaLauncherDialogService service = CreateService(); + + ManualModificationDialogResult? result = + await OwnedDialogTestHost.RunAsync( + dialog => dialog.ViewModel.CancelCommand.Execute(null), + owner => service.ShowManualModificationImportAsync( + new[] { @"C:\Packages\patch.zip" }, + owner)); + + result.Should().BeNull(); + }); + } + + [Fact] + public void ShowWarningConfirmationAsync_PreservesCustomTextAndDetailFontSize() + { + StaTestRunner.Run(async () => + { + double observedFontSize = 0; + string? observedContinueText = null; + string? observedCancelText = null; + bool observedWarningIcon = false; + AvaloniaLauncherDialogService service = CreateService(new FakeStringLocalizer( + new Dictionary + { + ["Continue"] = "Continue", + ["Cancel"] = "Cancel" + })); + LauncherInfoDialogRequest request = new( + "Unsafe operation", + "This operation changes managed files.", + 12.5, + "Go back"); + + bool confirmed = await OwnedDialogTestHost.RunAsync( + dialog => + { + TextBlock detailMessage = + dialog.FindControl("DetailMessageText") ?? + throw new InvalidOperationException("The detail message control was not created."); + Button continueButton = + dialog.FindControl + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/GenLauncherGO.UI/Features/Launcher/Views/MainWindow.axaml.cs b/GenLauncherGO.UI/Features/Launcher/Views/MainWindow.axaml.cs new file mode 100644 index 00000000..760cfb9a --- /dev/null +++ b/GenLauncherGO.UI/Features/Launcher/Views/MainWindow.axaml.cs @@ -0,0 +1,613 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; +using GenLauncherGO.Core.Launching; +using GenLauncherGO.Core.Launching.Models; +using GenLauncherGO.Core.Mods.Models; +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.Startup; +using GenLauncherGO.UI.Shared.Controls; +using GenLauncherGO.UI.Shared.Errors; + +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 +{ + private readonly HashSet> _activeWindowOperations = []; + private readonly LauncherWindowListController _contentController = null!; + private readonly LauncherDragDropController _dragDropController = null!; + private readonly IUiExceptionBoundary _exceptionBoundary = null!; + private readonly LauncherContentActionCoordinator _contentActionCoordinator = null!; + private readonly MainWindowViewModel _viewModel = null!; + private readonly LauncherWindowContext _windowContext = null!; + private readonly LauncherWindowWorkflowCoordinator _workflowCoordinator = null!; + private bool _closeApproved; + private bool _closePreparationInProgress; + private CancellationTokenSource _windowLifetime = new(); + + public MainWindow() + { + InitializeComponent(); + LauncherWindowScaling.Attach(this); + } + + public MainWindow( + MainWindowViewModel viewModel, + LauncherDragDropController dragDropController, + LauncherRuntimeContext runtimeContext, + LauncherWindowWorkflowCoordinator workflowCoordinator, + LauncherContentActionCoordinator contentActionCoordinator, + IUiExceptionBoundary exceptionBoundary) + : this() + { + _viewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel)); + _dragDropController = dragDropController ?? throw new ArgumentNullException(nameof(dragDropController)); + ArgumentNullException.ThrowIfNull(runtimeContext); + _workflowCoordinator = workflowCoordinator ?? throw new ArgumentNullException(nameof(workflowCoordinator)); + _contentActionCoordinator = contentActionCoordinator ?? + throw new ArgumentNullException(nameof(contentActionCoordinator)); + _exceptionBoundary = exceptionBoundary ?? throw new ArgumentNullException(nameof(exceptionBoundary)); + + DataContext = _viewModel; + _contentController = new LauncherWindowListController( + _viewModel, + runtimeContext, + ModsList, + PatchesList, + AddonsList); + _windowContext = new LauncherWindowContext(_viewModel, _contentController, this); + Closing += MainWindow_ClosingAsync; + Opened += MainWindow_OpenedAsync; + Activated += MainWindow_Activated; + Deactivated += MainWindow_Deactivated; + ModsList.AddHandler( + PointerPressedEvent, + ModsList_PointerPressed, + RoutingStrategies.Tunnel); + ModsList.AddHandler( + PointerMovedEvent, + ModsList_PointerMoved, + RoutingStrategies.Tunnel); + ModsList.AddHandler( + PointerReleasedEvent, + ModsList_PointerReleased, + RoutingStrategies.Tunnel); + ModsList.PointerCaptureLost += ModsList_PointerCaptureLost; + PatchesList.AddHandler( + PointerPressedEvent, + PatchesList_PointerPressed, + RoutingStrategies.Tunnel); + PatchesList.AddHandler( + PointerReleasedEvent, + PatchesList_PointerReleased, + RoutingStrategies.Tunnel); + _viewModel.PropertyChanged += ViewModel_PropertyChangedAsync; + + _viewModel.Initialize(); + _contentController.Initialize(); + UpdateContentViewVisibility(); + } + + private async void MainWindow_OpenedAsync(object? sender, EventArgs eventArgs) + { + _contentController.RestoreModsListVerticalOffset(); + await ShowRetailClientRecommendationIfNeededAsync(); + } + + private void MainWindow_Activated(object? sender, EventArgs eventArgs) + { + _viewModel.RefreshGameClientOptions(); + _viewModel.RefreshWorldBuilderOptions(); + } + + private void MainWindow_Deactivated(object? sender, EventArgs eventArgs) + { + CancelContentPointerGesture(); + } + + private void ModsList_PointerPressed(object? sender, PointerPressedEventArgs eventArgs) + { + _dragDropController.CapturePointerGesture( + ModsList, + this, + eventArgs, + true); + } + + private void ModsList_PointerMoved(object? sender, PointerEventArgs eventArgs) + { + if (_dragDropController.HandlePointerMove(ModsList, eventArgs)) + { + UpdateDragPreview(eventArgs.GetPosition(DragLayer)); + } + } + + private void ModsList_PointerReleased(object? sender, PointerReleasedEventArgs eventArgs) + { + CompleteContentPointerGesture(ModsList, eventArgs); + } + + private void ModsList_PointerCaptureLost(object? sender, PointerCaptureLostEventArgs eventArgs) + { + CancelContentPointerGesture(); + } + + private void PatchesList_PointerPressed(object? sender, PointerPressedEventArgs eventArgs) + { + _dragDropController.CapturePointerGesture( + PatchesList, + this, + eventArgs, + false); + } + + private void PatchesList_PointerReleased(object? sender, PointerReleasedEventArgs eventArgs) + { + CompleteContentPointerGesture(PatchesList, eventArgs); + } + + private void CompleteContentPointerGesture( + ListBox contentList, + PointerReleasedEventArgs eventArgs) + { + bool moved = _dragDropController.TryCompletePointerGesture( + contentList, + eventArgs, + out bool selectionCleared, + out int sourceIndex, + out int targetIndex); + if (moved) + { + _viewModel.MoveModInList(sourceIndex, targetIndex); + } + + if (moved || selectionCleared) + { + eventArgs.Handled = true; + } + + HideDragPreview(); + } + + private void TileContextMenu_Opened(object? sender, RoutedEventArgs eventArgs) + { + CancelContentPointerGesture(); + } + + private void CancelContentPointerGesture() + { + _dragDropController?.CancelPointerGesture(); + HideDragPreview(); + } + + private void UpdateDragPreview(Point pointerPosition) + { + if (_dragDropController.DraggedModification is not { } modification) + { + HideDragPreview(); + return; + } + + const double PreviewWidth = 360; + const double PreviewHeight = 72; + const double PointerOffset = 18; + double layerWidth = DragLayer.Bounds.Width; + double layerHeight = DragLayer.Bounds.Height; + double horizontalPosition = pointerPosition.X + PointerOffset; + if (horizontalPosition + PreviewWidth > layerWidth) + { + horizontalPosition = pointerPosition.X - PreviewWidth - PointerOffset; + } + + ModificationDragPreview.DataContext = modification; + Canvas.SetLeft( + ModificationDragPreview, + Math.Clamp(horizontalPosition, 0, Math.Max(0, layerWidth - PreviewWidth))); + Canvas.SetTop( + ModificationDragPreview, + Math.Clamp( + pointerPosition.Y - PreviewHeight / 2, + 0, + Math.Max(0, layerHeight - PreviewHeight))); + ModificationDragPreview.IsVisible = true; + } + + private void HideDragPreview() + { + ModificationDragPreview.IsVisible = false; + ModificationDragPreview.DataContext = null; + } + + private async void ModsList_SelectionChangedAsync(object? sender, SelectionChangedEventArgs eventArgs) + { + await ExecuteWindowOperationAsync( + "changing the selected modification", + () => _contentController.HandleModsListSelectionChangedAsync(eventArgs)); + } + + private async void ChildContentList_SelectionChangedAsync(object? sender, SelectionChangedEventArgs eventArgs) + { + await ExecuteSyncAsync( + "changing selected child content", + () => _contentController.HandleChildContentListSelectionChanged(eventArgs)); + } + + private async void VersionsList_SelectionChangedAsync(object? sender, SelectionChangedEventArgs eventArgs) + { + await ExecuteSyncAsync( + "changing a selected content version", + () => _contentController.HandleVersionsListSelectionChanged(sender!)); + } + + private async void MainWindow_ClosingAsync(object? sender, WindowClosingEventArgs eventArgs) + { + if (_closeApproved) + { + return; + } + + // Cancel the first close request so package cancellation and terminal cleanup finish before the + // Avalonia desktop lifetime disposes application services. + eventArgs.Cancel = true; + if (_closePreparationInProgress) + { + return; + } + + if (!await _workflowCoordinator.ConfirmCloseDuringActiveOperationsAsync(this)) + { + return; + } + + _closePreparationInProgress = true; + IsEnabled = false; + Task[] activeWindowOperations = _activeWindowOperations + .Where(operation => !operation.IsCompleted) + .ToArray(); + UiOperationOutcome outcome = await _exceptionBoundary.ExecuteAsync( + "preparing the launcher to close", + async () => + { + await Task.Yield(); + _windowLifetime.Cancel(); + try + { + await _workflowCoordinator.PrepareForCloseAsync(); + } + finally + { + await Task.WhenAll(activeWindowOperations); + } + + _contentController.SaveModsListVerticalOffset(); + _viewModel.SaveLauncherData(); + }, + this); + + if (outcome != UiOperationOutcome.Succeeded) + { + _windowLifetime.Dispose(); + _windowLifetime = new CancellationTokenSource(); + _closePreparationInProgress = false; + IsEnabled = true; + return; + } + + CancelContentPointerGesture(); + _viewModel.PropertyChanged -= ViewModel_PropertyChangedAsync; + _viewModel.Dispose(); + _windowLifetime.Dispose(); + _closeApproved = true; + Close(); + } + + private async void LaunchGame_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ExecuteWindowOperationAsync( + "launching the selected game", + () => _workflowCoordinator.LaunchAsync( + GameLaunchTargetKind.GameClient, + _windowContext, + _windowLifetime.Token)); + } + + private async void LaunchWorldBuilder_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ExecuteWindowOperationAsync( + "launching World Builder", + () => _workflowCoordinator.LaunchAsync( + GameLaunchTargetKind.WorldBuilder, + _windowContext, + _windowLifetime.Token)); + } + + private async void ToggleWindowedMode_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ExecuteSyncAsync( + "changing the windowed-mode preference", + () => _viewModel.ToggleGameArgument(LauncherGameArgumentService.WindowedArgument)); + } + + private async void ToggleQuickStart_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ExecuteSyncAsync( + "changing the quick-start preference", + () => _viewModel.ToggleGameArgument(LauncherGameArgumentService.QuickStartArgument)); + } + + private async void OpenOptions_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ExecuteWindowOperationAsync( + "opening launcher settings", + () => _workflowCoordinator.OpenOptionsAsync( + _windowContext, + _windowLifetime.Token)); + } + + private void Close_Click(object? sender, RoutedEventArgs eventArgs) + { + Close(); + } + + private async void ShowModifications_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ShowContentViewAsync(LauncherContentViewKind.Modifications, "showing the modifications view"); + } + + private async void ShowPatches_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ShowContentViewAsync(LauncherContentViewKind.Patches, "showing the patches view"); + } + + private async void ShowAddons_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ShowContentViewAsync(LauncherContentViewKind.Addons, "showing the add-ons view"); + } + + private async Task ShowContentViewAsync( + LauncherContentViewKind viewKind, + string operationContext) + { + await ExecuteWindowOperationAsync( + operationContext, + () => _viewModel.ShowContentViewAsync(viewKind, _windowLifetime.Token)); + } + + private async void AddRepositoryModification_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ExecuteWindowOperationAsync( + "adding a repository modification", + () => _contentActionCoordinator.AddRepositoryModificationAsync( + _windowContext, + _windowLifetime.Token)); + } + + private async void ImportManualModification_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ImportManualContentAsync(ModificationType.Mod); + } + + private async void ImportManualPatch_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ImportManualContentAsync(ModificationType.Patch); + } + + private async void ImportManualAddon_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ImportManualContentAsync(ModificationType.Addon); + } + + private async Task ImportManualContentAsync(ModificationType kind) + { + await ExecuteWindowOperationAsync( + "importing manual content", + () => _contentActionCoordinator.ImportManualContentAsync( + _windowContext, + kind, + _windowLifetime.Token)); + } + + private async void UpdateModification_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + if (GetModification(sender) is not { } modification) + { + return; + } + + await ExecuteWindowOperationAsync( + "updating launcher content", + () => _contentActionCoordinator.UpdateModificationAsync( + _windowContext, + modification)); + } + + private async void ChangeVersionImage_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + if (GetModification(sender) is not { } modification) + { + return; + } + + await ExecuteWindowOperationAsync( + "changing a modification image", + () => _contentActionCoordinator.ChangeVersionImageAsync( + _windowContext, + modification, + _windowLifetime.Token)); + } + + /// + /// Opens the tile link named by the invoking control's . + /// + private async void OpenTileLink_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + if (sender is not Control { Tag: LauncherTileLinkKind kind }) + { + return; + } + + await ApplyModificationActionAsync( + sender, + $"opening a {kind} tile link", + modification => _contentActionCoordinator.OpenTileLink(modification, kind)); + } + + private async void DeleteVersion_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + if (sender is Control { DataContext: ModificationVersionSelection versionSelection }) + { + await ExecuteWindowOperationAsync( + "deleting a content version", + () => _contentActionCoordinator.DeleteVersionAsync( + _windowContext, + versionSelection)); + } + } + + private async void DeleteModification_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + if (GetModification(sender) is not { } modification) + { + return; + } + + await ExecuteWindowOperationAsync( + "deleting launcher content", + () => _contentActionCoordinator.DeleteModificationAsync( + _windowContext, + modification)); + } + + private async void ForceQuitRunningProcess_ClickAsync(object? sender, RoutedEventArgs eventArgs) + { + await ExecuteWindowOperationAsync( + "force closing the launched process", + () => _workflowCoordinator.ForceCloseRunningProcessAsync(this)); + } + + private async Task ApplyModificationActionAsync( + object? sender, + string operationContext, + Action action) + { + ModificationViewModel? modification = GetModification(sender); + if (modification != null) + { + await ExecuteSyncAsync(operationContext, () => action(modification)); + } + } + + private static ModificationViewModel? GetModification(object? sender) + { + return (sender as Control)?.DataContext as ModificationViewModel; + } + + private async void ViewModel_PropertyChangedAsync(object? sender, PropertyChangedEventArgs eventArgs) + { + if (eventArgs.PropertyName == nameof(MainWindowViewModel.SelectedGameClientOption)) + { + if (IsVisible) + { + await ShowRetailClientRecommendationIfNeededAsync(); + } + + return; + } + + if (eventArgs.PropertyName == nameof(MainWindowViewModel.ActiveContentView)) + { + UpdateContentViewVisibility(); + return; + } + + if (eventArgs.PropertyName != nameof(MainWindowViewModel.ShouldHideLauncherWindow)) + { + return; + } + + if (_viewModel.ShouldHideLauncherWindow) + { + Hide(); + } + else if (!_closePreparationInProgress) + { + Show(); + } + } + + private Task ShowRetailClientRecommendationIfNeededAsync() + { + return ExecuteWindowOperationAsync( + "showing the retail client recommendation", + () => _workflowCoordinator.ShowRetailClientRecommendationIfNeededAsync( + _viewModel.SelectedGameClientOption, + this)); + } + + private void UpdateContentViewVisibility() + { + bool modificationsVisible = + _viewModel.ActiveContentView == LauncherContentViewKind.Modifications; + bool patchesVisible = + _viewModel.ActiveContentView == LauncherContentViewKind.Patches; + bool addonsVisible = + _viewModel.ActiveContentView == LauncherContentViewKind.Addons; + + ModsList.IsVisible = modificationsVisible; + PatchesList.IsVisible = patchesVisible; + AddonsList.IsVisible = addonsVisible; + ManualAddMod.IsVisible = modificationsVisible; + ManualAddPatch.IsVisible = patchesVisible; + ManualAddAddon.IsVisible = addonsVisible; + } + + private async Task ExecuteWindowOperationAsync( + string operationContext, + Func operation) + { + if (_closePreparationInProgress) + { + return; + } + + Task operationTask = _exceptionBoundary.ExecuteAsync( + operationContext, + operation, + this); + _activeWindowOperations.Add(operationTask); + try + { + await operationTask; + } + finally + { + _activeWindowOperations.Remove(operationTask); + } + } + + private async Task ExecuteSyncAsync(string operationContext, Action action) + { + await _exceptionBoundary.ExecuteAsync( + operationContext, + () => + { + action(); + return Task.CompletedTask; + }, + this); + } +} diff --git a/GenLauncherGO.UI/Features/Mods/ModificationImageSourceFactory.cs b/GenLauncherGO.UI/Features/Mods/ModificationImageSourceFactory.cs new file mode 100644 index 00000000..89ce346d --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ModificationImageSourceFactory.cs @@ -0,0 +1,208 @@ +using System; +using System.Collections.Concurrent; +using System.Globalization; +using System.IO; +using System.Runtime.InteropServices; +using Avalonia.Media.Imaging; +using Avalonia.Platform; +using GenLauncherGO.Core.Startup; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.UI.Features.Mods; + +/// +/// Creates Avalonia bitmaps for modification tiles without extracting generated image variants to disk. +/// +internal sealed class ModificationImageSourceFactory +{ + private const string DefaultImageResourceNamePrefix = "GenLauncherGO.UI.Features.Mods.Resources."; + + private readonly ConcurrentDictionary + _colorImageCache = new(StringComparer.OrdinalIgnoreCase); + + private readonly ConcurrentDictionary _grayscaleImageCache = + new(StringComparer.OrdinalIgnoreCase); + + private readonly ILogger _logger; + + public ModificationImageSourceFactory(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + } + + /// + /// Loads the default unknown modification image for the currently managed game. + /// + public Bitmap 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. + /// + /// + /// This method reads the file into memory before returning so callers can safely replace or delete the source file + /// after a successful load. + /// + public Bitmap? LoadFileImage(string? path, bool grayscale) + { + if (string.IsNullOrWhiteSpace(path) || !File.Exists(path)) + { + return null; + } + + FileInfo fileInfo = new(path); + string? cacheKey = null; + + try + { + cacheKey = CreateFileCacheKey(fileInfo); + Bitmap colorImage = GetOrLoadImage(cacheKey, () => DecodeFileImage(fileInfo.FullName)); + return grayscale ? GetOrCreateGrayscaleImage(cacheKey, colorImage) : colorImage; + } + catch (Exception exception) when (exception is IOException or NotSupportedException + or UnauthorizedAccessException) + { + if (cacheKey != null) + { + 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. + /// + private static string CreateFileCacheKey(FileInfo fileInfo) + { + fileInfo.Refresh(); + return string.Concat( + "file:", + fileInfo.FullName, + ":", + fileInfo.Length.ToString(CultureInfo.InvariantCulture), + ":", + fileInfo.LastWriteTimeUtc.Ticks.ToString(CultureInfo.InvariantCulture)); + } + + private static Bitmap DecodeFileImage(string path) + { + using FileStream stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); + return DecodeStreamImage(stream); + } + + private Bitmap LoadResourceImage(string resourceName, bool grayscale) + { + string cacheKey = "resource:" + resourceName; + + try + { + Bitmap 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; + } + } + + private static Bitmap 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); + } + } + + private static Bitmap DecodeStreamImage(Stream stream) + { + return new Bitmap(stream); + } + + private Bitmap GetOrLoadImage(string cacheKey, Func imageFactory) + { + return _colorImageCache.GetOrAdd(cacheKey, _ => imageFactory()); + } + + private Bitmap GetOrCreateGrayscaleImage(string cacheKey, Bitmap source) + { + return _grayscaleImageCache.GetOrAdd(cacheKey, _ => CreateGrayscaleImage(source)); + } + + /// + /// Converts a decoded bitmap to grayscale while preserving its alpha channel. + /// + private static Bitmap CreateGrayscaleImage(Bitmap source) + { + WriteableBitmap grayscale = new( + source.PixelSize, + source.Dpi, + PixelFormat.Bgra8888, + AlphaFormat.Premul); + using (ILockedFramebuffer framebuffer = grayscale.Lock()) + { + source.CopyPixels(framebuffer); + + int redOffset; + int blueOffset; + if (framebuffer.Format == PixelFormat.Bgra8888) + { + redOffset = 2; + blueOffset = 0; + } + else if (framebuffer.Format == PixelFormat.Rgba8888) + { + redOffset = 0; + blueOffset = 2; + } + else + { + throw new NotSupportedException( + "The writable bitmap did not expose a supported 32-bit pixel format."); + } + + byte[] row = new byte[framebuffer.RowBytes]; + for (int y = 0; y < framebuffer.Size.Height; y++) + { + nint rowAddress = framebuffer.Address + checked(y * framebuffer.RowBytes); + Marshal.Copy(rowAddress, row, 0, row.Length); + for (int x = 0; x < framebuffer.Size.Width; x++) + { + int pixelOffset = x * 4; + int luminance = + row[pixelOffset + redOffset] * 77 + + row[pixelOffset + 1] * 150 + + row[pixelOffset + blueOffset] * 29; + byte gray = (byte)((luminance + 128) >> 8); + row[pixelOffset] = gray; + row[pixelOffset + 1] = gray; + row[pixelOffset + 2] = gray; + } + + Marshal.Copy(row, 0, rowAddress, row.Length); + } + } + + return grayscale; + } + + 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..1adac6e0 --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ModificationVersionSelection.cs @@ -0,0 +1,32 @@ +using System; +using System.Linq; +using GenLauncherGO.Core.Mods.Models; + +namespace GenLauncherGO.UI.Features.Mods; + +internal sealed class ModificationVersionSelection +{ + public ModificationVersionSelection( + LauncherContentVersion selectedVersion, + ModificationViewModel modificationViewModel) + { + ArgumentNullException.ThrowIfNull(selectedVersion); + ArgumentNullException.ThrowIfNull(modificationViewModel); + if (!modificationViewModel.ContainerModification.Versions.Any(version => + version.ContentKey == selectedVersion.ContentKey)) + { + throw new ArgumentException( + "A selected version must belong to its modification tile.", + nameof(selectedVersion)); + } + + SelectedVersion = selectedVersion; + ModificationViewModel = modificationViewModel; + } + + public string VersionName => SelectedVersion.Version; + + public LauncherContentVersion SelectedVersion { get; } + + public ModificationViewModel ModificationViewModel { get; } +} diff --git a/GenLauncherGO.UI/Features/Mods/ModificationViewModel.cs b/GenLauncherGO.UI/Features/Mods/ModificationViewModel.cs new file mode 100644 index 00000000..35aba8c4 --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ModificationViewModel.cs @@ -0,0 +1,803 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using Avalonia; +using Avalonia.Media; +using Avalonia.Media.Immutable; +using CommunityToolkit.Mvvm.ComponentModel; +using GenLauncherGO.Core.Integrity.Models; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Updating.Models; +using GenLauncherGO.UI.Features.Integrity; +using GenLauncherGO.UI.Features.Mods.ViewModels; +using GenLauncherGO.UI.Features.Startup; +using GenLauncherGO.UI.Shared.Localization; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.UI.Features.Mods; + +/// +/// Represents bindable UI state for a launcher modification tile. +/// +internal sealed class ModificationViewModel : ObservableObject, ILaunchContentIntegrityProgressTarget +{ + private readonly ModificationTileImageProvider _imageProvider; + + private readonly LauncherRuntimeContext _launcherContext; + + private readonly LauncherPackageActivityService _packageActivityService; + + private readonly ILauncherStringLocalizer _stringLocalizer; + + private bool _forwardedChildPackageActivityActive; + private bool _integrityProgressActive; + + public ModificationViewModel( + LauncherContent modification, + ModificationImageSourceFactory imageSourceFactory, + LauncherRuntimeContext launcherContext, + IModificationImageFileService modificationImageFileService, + ILauncherStringLocalizer stringLocalizer, + LauncherPackageActivityService packageActivityService, + ILogger logger) + { + _launcherContext = launcherContext ?? throw new ArgumentNullException(nameof(launcherContext)); + _stringLocalizer = stringLocalizer ?? throw new ArgumentNullException(nameof(stringLocalizer)); + _packageActivityService = packageActivityService ?? + throw new ArgumentNullException(nameof(packageActivityService)); + _imageProvider = new ModificationTileImageProvider( + imageSourceFactory, + launcherContext, + modificationImageFileService, + logger); + ContainerModification = modification ?? throw new ArgumentNullException(nameof(modification)); + ProgressBackground = ProgressBackgroundBrush; + ProgressForeground = ActiveProgressBrush; + ProgressBorderBrush = InactiveBrush; + ProgressTextForeground = DefaultTextBrush; + RefreshSelectedVersion(); + + UpdateButtonContent = _stringLocalizer["Update"]; + SupportButtonContent = _stringLocalizer["Donate"]; + ChangeLogButtonContent = _stringLocalizer["ChangelogOnly"]; + NetworkInfoButtonContent = _stringLocalizer["PlayOnline"]; + VersionActionContent = _stringLocalizer["RemoveFromList"]; + + InitializeVisualState(); + } + + public LauncherContent ContainerModification { get; } + + /// + /// Gets whether this card uses the compact patch and add-on presentation instead of the artwork presentation. + /// + public bool UsesCompactTileLayout => + ContainerModification.ModificationType is ModificationType.Addon or ModificationType.Patch; + + public LauncherContentVersion LatestVersion => ContainerModification.LatestVersion; + + public LauncherContentVersion? SelectedVersion { get; private set; } + + public string NameInfo => ContainerModification.Name; + + public string LatestVersionInfo => + ContainerModification.ModificationType == ModificationType.Advertising + ? LatestVersion.Version + : string.Concat(_stringLocalizer["LatestVersion"], LatestVersion.Version); + + public bool ReadyToRun { get; private set; } = true; + + public bool CanSetImage => + ContainerModification.ModificationType != ModificationType.Advertising && + LatestVersion.EffectiveContentSourceKind == ContentSourceKind.Manual; + + public bool CanOpenModDb => !string.IsNullOrEmpty(ContainerModification.LatestVersion.ModDBLink); + + public bool CanOpenDiscord => !string.IsNullOrEmpty(ContainerModification.LatestVersion.DiscordLink); + + public bool LocalMod => + ContainerModification.ModificationType == ModificationType.Mod && + !ContainerModification.Versions.Any(version => + version.EffectiveContentSourceKind.IsManagedRemote()); + + /// + /// Gets the palette this modification publishes for the launcher shell, or for none. + /// + public LauncherContentTheme? PublishedTheme => (SelectedVersion ?? LatestVersion).Theme; + + /// + /// Gets a value indicating whether package download, repair, or forwarded child activity is active. + /// + public bool HasActivePackageActivity => + _packageActivityService.GetActiveDownloadTask(this) is { IsCompleted: false } || + _integrityProgressActive || + _forwardedChildPackageActivityActive; + + public ObservableCollection VersionOptions { get; } = []; + + public ModificationVersionSelection? SelectedVersionOption + { + get; + set => SetProperty(ref field, value); + } + + public bool IsSelected + { + get; + set + { + if (SetProperty(ref field, value)) + { + OnPropertyChanged(nameof(IsSelectedOrAdvertising)); + } + } + } + + /// + /// Gets a value indicating whether selection-gated actions should be shown for this tile. + /// Advertising actions remain available without changing the selected game content. + /// + public bool IsSelectedOrAdvertising => + IsSelected || ContainerModification.ModificationType == ModificationType.Advertising; + + public IImage? ImageSource + { + get; + private set + { + if (SetProperty(ref field, value)) + { + OnPropertyChanged(nameof(HasImage)); + } + } + } + + public IImage? SelectedImageSource + { + get; + private set + { + if (SetProperty(ref field, value)) + { + OnPropertyChanged(nameof(HasImage)); + } + } + } + + public bool HasImage => ImageSource != null || SelectedImageSource != null; + + public bool IsVersionSelectorVisible + { + get; + private set => SetProperty(ref field, value); + } + + public bool IsVersionActionVisible + { + get; + private set => SetProperty(ref field, value); + } + + public bool IsDragAndDropVisible + { + get; + private set => SetProperty(ref field, value); + } + + public bool IsUpdateButtonVisible + { + get; + private set => SetProperty(ref field, value); + } = true; + + public bool IsSupportButtonVisible + { + get; + private set => SetProperty(ref field, value); + } = true; + + public bool IsNetworkInfoVisible + { + get; + private set => SetProperty(ref field, value); + } = true; + + public bool IsChangeLogVisible + { + get; + private set => SetProperty(ref field, value); + } = true; + + public Thickness ImageBorderThickness + { + get; + private set => SetProperty(ref field, value); + } = new(0); + + public IBrush ProgressBackground + { + get; + private set => SetProperty(ref field, value); + } + + public IBrush ProgressForeground + { + get; + private set => SetProperty(ref field, value); + } + + public IBrush ProgressBorderBrush + { + get; + private set => SetProperty(ref field, value); + } + + public IBrush ProgressTextForeground + { + get; + private set => SetProperty(ref field, value); + } + + public double ProgressValue + { + get; + private set => SetProperty(ref field, value); + } + + public string ProgressMessage + { + get; + private set => SetProperty(ref field, value); + } = string.Empty; + + public string UpdateButtonContent + { + get; + private set => SetProperty(ref field, value); + } + + public string SupportButtonContent + { + get; + private set => SetProperty(ref field, value); + } + + public string ChangeLogButtonContent + { + get; + private set => SetProperty(ref field, value); + } + + public string NetworkInfoButtonContent + { + get; + private set => SetProperty(ref field, value); + } + + public bool UpdateButtonEnabled + { + get; + private set => SetProperty(ref field, value); + } = true; + + public bool UpdateButtonBlinking + { + get; + private set => SetProperty(ref field, value); + } + + public bool SupportButtonBlinking + { + get; + private set => SetProperty(ref field, value); + } + + public bool IsVersionSelectorEnabled + { + get; + private set => SetProperty(ref field, value); + } = true; + + public string VersionActionContent + { + get; + private set => SetProperty(ref field, value); + } + + // Tiles push brushes into bindable properties instead of resolving DynamicResource, so they read the theme + // directly rather than through application resources. + private IBrush ActiveBrush => _launcherContext.Colors.GenLauncherActiveColor; + + private IBrush BorderBrush => _launcherContext.Colors.GenLauncherBorderColor; + + private IBrush DefaultTextBrush => _launcherContext.Colors.GenLauncherDefaultTextColor; + + private IBrush DownloadTextBrush => _launcherContext.Colors.GenLauncherDownloadTextColor; + + private IBrush InactiveBrush => _launcherContext.Colors.GenLauncherInactiveBorder; + + private IBrush ProgressBackgroundBrush => _launcherContext.Colors.GenLauncherDarkBackGround; + + private IBrush ActiveProgressBrush => + new ImmutableSolidColorBrush(_launcherContext.Colors.GenLauncherButtonSelectionColor); + + public LauncherContentVersion ActiveIntegrityVersion => SelectedVersion ?? LatestVersion; + + public void BeginIntegrityProgress(string message) + { + _integrityProgressActive = true; + ApplyPackageActivityVisualState(true); + ReportPackageProgress(message, 0); + } + + public void ReportIntegrityProgress(string message, int percentage) + { + ReportPackageProgress(message, percentage); + } + + public void CompleteIntegrityProgress() + { + _integrityProgressActive = false; + RefreshFromModelAndPresentation(); + ApplyPackageActivityVisualState(false); + OnPackageActivityChanged(); + } + + /// + /// Occurs when package download or repair activity state changes for this tile. + /// + public event EventHandler? PackageActivityChanged; + + /// + /// Loads the cached shell artwork published alongside . + /// + public IImageBrush? LoadPublishedThemeBackground() + { + return _imageProvider.LoadThemeBackground( + ContainerModification, + SelectedVersion ?? LatestVersion); + } + + public void RefreshFromModel() + { + RefreshSelectedVersion(); + OnStatePropertiesChanged(); + } + + public void SetDragAndDropMod() + { + IsDragAndDropVisible = true; + } + + public void RemoveDragAndDropMod() + { + IsDragAndDropVisible = false; + } + + public void RefreshPresentation() + { + ApplyPackageActivityVisualState(HasActivePackageActivity); + RefreshImages(); + } + + private void ApplyPackageActivityVisualState(bool isActive) + { + if (!isActive) + { + ProgressBackground = ProgressBackgroundBrush; + ProgressForeground = ActiveBrush; + ProgressBorderBrush = InactiveBrush; + ProgressTextForeground = DefaultTextBrush; + return; + } + + ProgressBackground = ActiveProgressBrush; + ProgressForeground = ActiveBrush; + ProgressBorderBrush = BorderBrush; + ProgressTextForeground = DownloadTextBrush; + } + + /// + /// Updates bindable tile state from current modification and download state. + /// + public void RefreshFromModelAndPresentation() + { + if (_packageActivityService.GetActiveDownloadTask(this) is not { IsCompleted: false }) + { + ResetDownloadVisuals(); + + RefreshFromModel(); + + if (ContainerModification.ModificationType != ModificationType.Advertising) + { + UpdateComboBox(); + SelectItemInComboBox(); + } + else + { + HideVersionSelector(); + } + } + + RefreshContentButtonAvailability(); + RefreshImages(); + } + + public void UpdateComboBox() + { + if (LatestVersion.Installation.Installed) + { + UpdateButtonContent = _stringLocalizer["UpToDate"]; + UpdateButtonEnabled = false; + UpdateButtonBlinking = false; + } + else + { + UpdateButtonContent = _stringLocalizer["Update"]; + UpdateButtonEnabled = true; + UpdateButtonBlinking = false; + } + + VersionOptions.Clear(); + foreach (LauncherContentVersion version in ContainerModification.Versions + .Where(modificationVersion => modificationVersion.Installation.Installed) + .OrderBy(modificationVersion => modificationVersion)) + { + VersionOptions.Add(new ModificationVersionSelection( + version, + this)); + } + } + + public void SelectItemInComboBox() + { + if (ContainerModification.Versions.Count == 0) + { + IsVersionSelectorEnabled = false; + SelectedVersionOption = null; + return; + } + + if (ContainerModification.Versions.Count == 1 && !LatestVersion.Installation.Installed) + { + ApplyInstallAvailableState(); + SelectedVersionOption = null; + return; + } + + IsVersionSelectorEnabled = true; + string versionString; + if (ReadyToRun) + { + versionString = SelectedVersion?.Version ?? string.Empty; + } + else + { + LauncherContentVersion selectedVersion = SelectLatestInstalledVersion(); + OnStatePropertiesChanged(); + versionString = selectedVersion.Version; + } + + SelectedVersionOption = VersionOptions.FirstOrDefault(selection => + string.Equals(selection.VersionName, versionString, StringComparison.Ordinal)); + } + + /// + /// Projects the lifecycle owner's single terminal package result onto this tile. + /// + public void CompletePackageActivityPresentation(PackageDownloadResult result) + { + ArgumentNullException.ThrowIfNull(result); + + // Captured before the reset below clears it, because a suspended download restores this exact position. + double progressAtCompletion = ProgressValue; + try + { + RefreshFromModelAndPresentation(); + ApplyTerminalDownloadResult(result, progressAtCompletion); + } + finally + { + OnPackageActivityChanged(); + } + } + + private void ApplyTerminalDownloadResult(PackageDownloadResult result, double progressAtCompletion) + { + switch (result.Status) + { + case PackageDownloadStatus.Succeeded: + ClearSuspendedDownload(); + ApplyPackageActivityVisualState(false); + break; + case PackageDownloadStatus.Canceled: + ClearSuspendedDownload(); + SetStatusMessage(_stringLocalizer["Canceled"]); + ApplyPackageActivityVisualState(false); + break; + case PackageDownloadStatus.Suspended: + RecordSuspendedDownload(progressAtCompletion); + break; + case PackageDownloadStatus.RecoverableFailure: + ShowDownloadFailure(result.Message); + break; + case PackageDownloadStatus.UnexpectedFailure: + ShowDownloadFailure(_stringLocalizer["UnexpectedErrorDetails"]); + break; + default: + throw new ArgumentOutOfRangeException( + nameof(result), + result.Status, + "Unknown package download status."); + } + } + + /// + /// Marks the version's partial content as kept and leaves the tile showing where the transfer stopped. + /// + private void RecordSuspendedDownload(double progressAtCompletion) + { + LauncherContentVersion version = SelectedVersion ?? LatestVersion; + version.Installation.DownloadSuspended = true; + version.Installation.SuspendedProgressPercentage = progressAtCompletion; + + ShowSuspendedDownload(progressAtCompletion); + } + + /// + /// Restores the paused progress a previous session left behind, so the tile reopens where it stopped. + /// + private void RestoreSuspendedDownload() + { + LauncherContentVersion version = SelectedVersion ?? LatestVersion; + if (version.Installation.DownloadSuspended) + { + ShowSuspendedDownload(version.Installation.SuspendedProgressPercentage); + } + } + + private void ShowSuspendedDownload(double progressPercentage) + { + ProgressValue = progressPercentage; + SetStatusMessage(_stringLocalizer["Paused"]); + UpdateButtonContent = _stringLocalizer["Resume"]; + ApplyPackageActivityVisualState(false); + IsUpdateButtonVisible = true; + SetUpdateButtonEnabled(true); + } + + /// + /// Drops the suspended marker once the version is no longer waiting to be resumed. + /// + private void ClearSuspendedDownload() + { + foreach (LauncherContentVersion version in ContainerModification.Versions) + { + version.Installation.DownloadSuspended = false; + version.Installation.SuspendedProgressPercentage = 0; + } + } + + private void ShowDownloadFailure(string message) + { + SetStatusMessage(string.Concat(_stringLocalizer["Error"], message)); + ApplyPackageActivityVisualState(false); + } + + /// + /// Prepares tile state for package download state. + /// + public void BeginPackageActivityPresentation() + { + // Starting a transfer settles whatever a previous session suspended, whether it resumes or restarts it. + ClearSuspendedDownload(); + UpdateButtonContent = _stringLocalizer["Pause"]; + UpdateButtonBlinking = false; + IsVersionSelectorEnabled = false; + ReadyToRun = false; + OnStatePropertiesChanged(); + + RefreshContentButtonAvailability(); + ApplyPackageActivityVisualState(true); + OnPackageActivityChanged(); + } + + /// + /// Updates the active download action to reflect whether the transfer is paused. + /// + public void SetPackageDownloadPaused(bool isPaused) + { + UpdateButtonContent = _stringLocalizer[isPaused ? "Resume" : "Pause"]; + } + + /// + /// Starts the one-time install notification for a newly added repository modification. + /// + public void NotifyInstallAvailable() + { + if (ContainerModification.ModificationType == ModificationType.Mod && + !LatestVersion.Installation.Installed) + { + UpdateButtonBlinking = true; + } + } + + public void SetStatusMessage(string message) + { + ProgressMessage = message; + } + + public void SetUpdateButtonEnabled(bool isEnabled) + { + UpdateButtonEnabled = isEnabled; + } + + public void SetSupportButtonBlinking(bool isBlinking) + { + SupportButtonBlinking = isBlinking; + } + + public void ReportPackageProgress(string message, int percentage) + { + ProgressMessage = message; + ProgressValue = percentage; + if (HasActivePackageActivity) + { + OnPackageActivityChanged(); + } + } + + private void ApplyInstallAvailableState() + { + IsVersionSelectorEnabled = false; + UpdateButtonContent = _stringLocalizer["Install"]; + ReadyToRun = false; + OnStatePropertiesChanged(); + } + + /// + /// Mirrors child-content package activity onto this parent tile. + /// + public void ReportForwardedChildPackageActivity(string message, int percentage) + { + if (_packageActivityService.GetActiveDownloadTask(this) is { IsCompleted: false } || + _integrityProgressActive) + { + return; + } + + _forwardedChildPackageActivityActive = true; + ApplyPackageActivityVisualState(true); + ReportPackageProgress(message, percentage); + } + + /// + /// Clears mirrored child-content package activity from this parent tile. + /// + public void CompleteForwardedChildPackageActivity() + { + if (!_forwardedChildPackageActivityActive) + { + return; + } + + _forwardedChildPackageActivityActive = false; + RefreshFromModelAndPresentation(); + ApplyPackageActivityVisualState(false); + OnPackageActivityChanged(); + } + + private void InitializeVisualState() + { + ResetDownloadVisuals(); + RefreshFromModelAndPresentation(); + RestoreSuspendedDownload(); + } + + private void ResetDownloadVisuals() + { + ProgressValue = 0; + ProgressMessage = string.Empty; + IsUpdateButtonVisible = true; + 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.LatestVersion.SimpleDownloadLink)) + { + IsUpdateButtonVisible = false; + } + + ChangeLogButtonContent = _stringLocalizer["AdvertisingBoostyLink"]; + NetworkInfoButtonContent = _stringLocalizer["AdvertisingYouTubeRuLink"]; + } + + private void HideVersionSelector() + { + IsVersionSelectorVisible = false; + } + + private void RefreshContentButtonAvailability() + { + bool isAdvertising = ContainerModification.ModificationType == ModificationType.Advertising; + bool hasActiveDownload = + _packageActivityService.GetActiveDownloadTask(this) is { IsCompleted: false }; + IsVersionSelectorVisible = !isAdvertising && + ContainerModification.Installed && + !hasActiveDownload; + IsVersionActionVisible = !isAdvertising && + (hasActiveDownload || + (ContainerModification.ModificationType == ModificationType.Mod && + !ContainerModification.Installed)); + VersionActionContent = _stringLocalizer[ + hasActiveDownload + ? "CancelDownloadAction" + : "RemoveFromList"]; + IsChangeLogVisible = !string.IsNullOrEmpty(ContainerModification.LatestVersion.NewsLink); + IsNetworkInfoVisible = !string.IsNullOrEmpty(ContainerModification.LatestVersion.NetworkInfo); + IsSupportButtonVisible = !string.IsNullOrEmpty(ContainerModification.LatestVersion.SupportLink); + } + + private void RefreshImages() + { + ImageSource = _imageProvider.LoadGrayscaleImage( + ContainerModification, + LatestVersion, + LocalMod); + SelectedImageSource = _imageProvider.LoadColorImage( + ContainerModification, + LatestVersion, + LocalMod); + ImageBorderThickness = ImageSource == null && SelectedImageSource == null + ? new Thickness(0) + : new Thickness(2); + } + + private void RefreshSelectedVersion() + { + SelectedVersion = ContainerModification.GetSelectedVersion(); + SelectedVersion?.Installation.IsSelected = true; + } + + private LauncherContentVersion SelectLatestInstalledVersion() + { + SelectedVersion?.Installation.IsSelected = false; + + SelectedVersion = ContainerModification.LatestInstalledVersion ?? + throw new InvalidOperationException( + "An installed version is required before it can be selected."); + SelectedVersion.Installation.IsSelected = true; + ReadyToRun = true; + return SelectedVersion; + } + + private void OnStatePropertiesChanged() + { + OnPropertyChanged(nameof(ContainerModification)); + OnPropertyChanged(nameof(LatestVersion)); + OnPropertyChanged(nameof(SelectedVersion)); + OnPropertyChanged(nameof(NameInfo)); + OnPropertyChanged(nameof(LatestVersionInfo)); + OnPropertyChanged(nameof(ReadyToRun)); + OnPropertyChanged(nameof(CanSetImage)); + OnPropertyChanged(nameof(CanOpenModDb)); + OnPropertyChanged(nameof(CanOpenDiscord)); + OnPropertyChanged(nameof(LocalMod)); + OnPropertyChanged(nameof(ActiveIntegrityVersion)); + } + + private void OnPackageActivityChanged() + { + OnPropertyChanged(nameof(HasActivePackageActivity)); + PackageActivityChanged?.Invoke(this, EventArgs.Empty); + } +} 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/AddModificationItemViewModel.cs b/GenLauncherGO.UI/Features/Mods/ViewModels/AddModificationItemViewModel.cs new file mode 100644 index 00000000..a0c23557 --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ViewModels/AddModificationItemViewModel.cs @@ -0,0 +1,50 @@ +using System; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace GenLauncherGO.UI.Features.Mods.ViewModels; + +/// +/// Presents one remotely available modification and its asynchronously resolved package metadata. +/// +internal sealed class AddModificationItemViewModel : ObservableObject +{ + private string _packageSizeText; + + public AddModificationItemViewModel(string name, string calculatingPackageSizeText) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + _packageSizeText = calculatingPackageSizeText ?? + throw new ArgumentNullException(nameof(calculatingPackageSizeText)); + } + + public string Name { get; } + + public string VersionText + { + get; + private set => SetProperty(ref field, value); + } = "\u2026"; + + public string PackageSizeText + { + get => _packageSizeText; + private set => SetProperty(ref _packageSizeText, value); + } + + public void SetMetadata(string versionText, string packageSizeText) + { + VersionText = string.IsNullOrWhiteSpace(versionText) ? "\u2014" : versionText; + PackageSizeText = packageSizeText; + } + + public void SetMetadataUnavailable(string packageSizeUnavailableText) + { + VersionText = "\u2014"; + PackageSizeText = packageSizeUnavailableText; + } + + public void SetPackageSize(string packageSizeText) + { + PackageSizeText = packageSizeText; + } +} diff --git a/GenLauncherGO.UI/Features/Mods/ViewModels/AddModificationViewModel.cs b/GenLauncherGO.UI/Features/Mods/ViewModels/AddModificationViewModel.cs new file mode 100644 index 00000000..503ff675 --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ViewModels/AddModificationViewModel.cs @@ -0,0 +1,260 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.Core.Updating.Contracts; +using GenLauncherGO.UI.Shared.Formatting; +using GenLauncherGO.UI.Shared.Localization; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace GenLauncherGO.UI.Features.Mods.ViewModels; + +/// +/// Provides searchable selection state and cancellable remote metadata loading for repository modifications. +/// +internal sealed class AddModificationViewModel : ObservableObject, IDisposable +{ + private const int MaxConcurrentVersionRequests = 6; + + private const int MaxConcurrentPackageSizeRequests = 6; + + private readonly IReadOnlyList _allModifications; + + private readonly ILauncherContentCatalog _catalog; + + private readonly ILogger _logger; + + private readonly CancellationTokenSource _metadataCancellation = new(); + + private readonly IRemotePackageSizeResolver _packageSizeResolver; + + private readonly string _packageSizeUnavailableText; + + private bool _metadataLoadingStarted; + private AddModificationItemViewModel? _selectedModification; + + public AddModificationViewModel( + IReadOnlyList modificationNames, + ILauncherContentCatalog catalog, + IRemotePackageSizeResolver packageSizeResolver, + ILauncherStringLocalizer stringLocalizer, + ILogger? logger = null) + { + ArgumentNullException.ThrowIfNull(modificationNames); + _catalog = catalog ?? throw new ArgumentNullException(nameof(catalog)); + _packageSizeResolver = packageSizeResolver ?? throw new ArgumentNullException(nameof(packageSizeResolver)); + ArgumentNullException.ThrowIfNull(stringLocalizer); + _logger = logger ?? NullLogger.Instance; + _packageSizeUnavailableText = stringLocalizer["PackageSizeUnavailable"]; + + _allModifications = modificationNames + .Select(name => new AddModificationItemViewModel( + name, + stringLocalizer["CalculatingPackageSize"])) + .ToList(); + VisibleModifications = new ObservableCollection(_allModifications); + _selectedModification = VisibleModifications.FirstOrDefault(); + AcceptCommand = new RelayCommand(AcceptSelection, () => CanAccept); + CancelCommand = new RelayCommand(Cancel); + } + + public ObservableCollection VisibleModifications { get; } + + public string SearchText + { + get; + set + { + string newValue = value ?? string.Empty; + if (string.Equals(field, newValue, StringComparison.Ordinal)) + { + return; + } + + field = newValue; + OnPropertyChanged(); + ApplyFilter(); + } + } = string.Empty; + + public AddModificationItemViewModel? SelectedModification + { + get => _selectedModification; + set + { + if (ReferenceEquals(_selectedModification, value)) + { + return; + } + + _selectedModification = value; + OnPropertyChanged(); + OnPropertyChanged(nameof(SelectedModificationName)); + OnPropertyChanged(nameof(CanAccept)); + AcceptCommand.NotifyCanExecuteChanged(); + } + } + + public string? SelectedModificationName => SelectedModification?.Name; + + public bool HasNoVisibleModifications => VisibleModifications.Count == 0; + + public bool CanAccept => + SelectedModification != null && VisibleModifications.Contains(SelectedModification); + + public IRelayCommand AcceptCommand { get; } + + public IRelayCommand CancelCommand { get; } + + public bool? DialogResult { get; private set; } + + public void Dispose() + { + _metadataCancellation.Dispose(); + } + + /// + /// Occurs when the view model requests that the owning dialog close. + /// + public event EventHandler? CloseRequested; + + /// + /// Loads version and package-size metadata with bounded concurrency until complete or canceled by dialog closure. + /// + public async Task LoadMetadataAsync() + { + if (_metadataLoadingStarted) + { + return; + } + + _metadataLoadingStarted = true; + using var versionGate = new SemaphoreSlim(MaxConcurrentVersionRequests); + using var packageSizeGate = new SemaphoreSlim(MaxConcurrentPackageSizeRequests); + try + { + await Task.WhenAll(_allModifications.Select(item => LoadMetadataAsync( + item, + versionGate, + packageSizeGate, + _metadataCancellation.Token))); + } + catch (OperationCanceledException) when (_metadataCancellation.IsCancellationRequested) + { + _logger.LogDebug("Canceled add-modification dialog metadata loading."); + } + } + + /// + /// Cancels remote metadata work when the dialog lifetime ends. + /// + public void CancelMetadataLoading() + { + if (!_metadataCancellation.IsCancellationRequested) + { + _metadataCancellation.Cancel(); + } + } + + private async Task LoadMetadataAsync( + AddModificationItemViewModel item, + SemaphoreSlim versionGate, + SemaphoreSlim packageSizeGate, + CancellationToken cancellationToken) + { + LauncherContentVersion version; + await versionGate.WaitAsync(cancellationToken); + try + { + try + { + version = await _catalog.GetRepositoryModificationMetadataAsync( + item.Name, + cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning( + "Failed to load repository metadata for modification {ModificationName}; failure type: {FailureType}.", + item.Name, + exception.GetType().Name); + item.SetMetadataUnavailable(_packageSizeUnavailableText); + return; + } + } + finally + { + versionGate.Release(); + } + + item.SetMetadata(version.Version, item.PackageSizeText); + await packageSizeGate.WaitAsync(cancellationToken); + try + { + long? totalBytes = await _packageSizeResolver.GetTotalBytesAsync(version, cancellationToken); + item.SetPackageSize(totalBytes.HasValue + ? ByteSizeFormatter.Format(totalBytes.Value) + : _packageSizeUnavailableText); + } + finally + { + packageSizeGate.Release(); + } + } + + private void ApplyFilter() + { + AddModificationItemViewModel? previousSelection = SelectedModification; + CompareInfo comparer = CultureInfo.CurrentCulture.CompareInfo; + var matchingItems = _allModifications + .Where(item => string.IsNullOrWhiteSpace(SearchText) || + comparer.IndexOf(item.Name, SearchText, CompareOptions.IgnoreCase) >= 0) + .ToList(); + + VisibleModifications.Clear(); + foreach (AddModificationItemViewModel item in matchingItems) + { + VisibleModifications.Add(item); + } + + SelectedModification = previousSelection != null && VisibleModifications.Contains(previousSelection) + ? previousSelection + : VisibleModifications.FirstOrDefault(); + OnPropertyChanged(nameof(HasNoVisibleModifications)); + OnPropertyChanged(nameof(CanAccept)); + AcceptCommand.NotifyCanExecuteChanged(); + } + + private void AcceptSelection() + { + if (!CanAccept) + { + return; + } + + CompleteDialog(true); + } + + private void Cancel() + { + CompleteDialog(false); + } + + private void CompleteDialog(bool accepted) + { + DialogResult = accepted; + 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..9e05af93 --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ViewModels/InfoDialogKind.cs @@ -0,0 +1,12 @@ +namespace GenLauncherGO.UI.Features.Mods.ViewModels; + +internal enum InfoDialogKind +{ + Info, + + InfoAction, + + Error, + + 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..4726a8d1 --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ViewModels/InfoDialogViewModel.cs @@ -0,0 +1,122 @@ +using System; +using CommunityToolkit.Mvvm.Input; +using GenLauncherGO.UI.Features.Dialogs.Models; + +namespace GenLauncherGO.UI.Features.Mods.ViewModels; + +internal sealed class InfoDialogViewModel +{ + public InfoDialogViewModel( + LauncherInfoDialogRequest request, + InfoDialogKind kind, + string? continueText = null, + string? cancelText = null, + string? actionText = null) + { + ArgumentNullException.ThrowIfNull(request); + + MainMessage = request.MainMessage; + DetailMessage = request.DetailMessage; + DetailFontSize = request.DetailFontSize ?? 15D; + ContinueText = string.IsNullOrWhiteSpace(continueText) ? null : continueText; + CancelText = string.IsNullOrWhiteSpace(cancelText) ? "Cancel" : cancelText; + ActionText = string.IsNullOrWhiteSpace(actionText) ? null : actionText; + IsInfoAction = kind == InfoDialogKind.InfoAction; + OkCommand = new RelayCommand(Accept); + CancelCommand = new RelayCommand(Cancel); + ContinueCommand = OkCommand; + ActionCommand = new RelayCommand(CompleteAction); + CloseCommand = new RelayCommand(Close); + + IsWarningConfirmation = kind == InfoDialogKind.WarningConfirmation; + IsOkVisible = kind is InfoDialogKind.Info or InfoDialogKind.Error; + IsActionVisible = IsInfoAction && ActionText != null; + IsContinueVisible = kind == InfoDialogKind.WarningConfirmation; + IsCancelVisible = kind == InfoDialogKind.WarningConfirmation; + IsInfoIconVisible = kind is InfoDialogKind.Info or InfoDialogKind.InfoAction; + IsWarningIconVisible = kind == InfoDialogKind.WarningConfirmation; + IsErrorIconVisible = kind == InfoDialogKind.Error; + } + + public string MainMessage { get; } + + public string DetailMessage { get; } + + public double DetailFontSize { get; } + + public string? ContinueText { get; } + + public string CancelText { get; } + + public string? ActionText { get; } + + public bool IsOkVisible { get; } + + public bool IsContinueVisible { get; } + + public bool IsActionVisible { get; } + + public bool IsCancelVisible { get; } + + public bool IsInfoIconVisible { get; } + + public bool IsWarningIconVisible { get; } + + public bool IsErrorIconVisible { get; } + + public IRelayCommand OkCommand { get; } + + public IRelayCommand CancelCommand { get; } + + public IRelayCommand ContinueCommand { get; } + + public IRelayCommand ActionCommand { get; } + + public IRelayCommand CloseCommand { get; } + + /// + /// Gets the result requested by the dialog command. + /// + public bool? DialogResult { get; private set; } + + private bool IsWarningConfirmation { get; } + + private bool IsInfoAction { get; } + + /// + /// Occurs when the view model requests that the owning dialog close. + /// + public event EventHandler? CloseRequested; + + private void Accept() + { + CompleteDialog(!IsInfoAction); + } + + private void CompleteAction() + { + CompleteDialog(true); + } + + private void Cancel() + { + CompleteDialog(false); + } + + private void CompleteDialog(bool result) + { + DialogResult = result; + CloseRequested?.Invoke(this, EventArgs.Empty); + } + + private void Close() + { + if (IsWarningConfirmation || IsInfoAction) + { + Cancel(); + return; + } + + Accept(); + } +} diff --git a/GenLauncherGO.UI/Features/Mods/ViewModels/ManualAddModificationViewModel.cs b/GenLauncherGO.UI/Features/Mods/ViewModels/ManualAddModificationViewModel.cs new file mode 100644 index 00000000..8f916cab --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ViewModels/ManualAddModificationViewModel.cs @@ -0,0 +1,240 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using GenLauncherGO.UI.Features.Dialogs.Contracts; +using GenLauncherGO.UI.Features.Dialogs.Models; +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 : ObservableObject +{ + private const string MissingModificationNameKey = "EnterModName"; + + private const string MissingVersionKey = "EnterModVersion"; + + private const string UnsupportedCharactersKey = "NameAndVersionValidSymbols"; + + private const string VersionMissingNumberKey = "VersionMustContainNumbers"; + + private readonly ILauncherDialogService _dialogService; + + private readonly ILauncherStringLocalizer _stringLocalizer; + + private string _modificationName; + + public ManualAddModificationViewModel( + IReadOnlyList files, + ILauncherStringLocalizer stringLocalizer, + ILauncherDialogService dialogService) + { + ArgumentNullException.ThrowIfNull(files); + + _stringLocalizer = stringLocalizer ?? throw new ArgumentNullException(nameof(stringLocalizer)); + _dialogService = dialogService ?? throw new ArgumentNullException(nameof(dialogService)); + _modificationName = InferModificationName(files); + AcceptCommand = new AsyncRelayCommand( + AcceptAsync, + () => CanAccept, + AsyncRelayCommandOptions.AllowConcurrentExecutions); + CancelCommand = new RelayCommand(Cancel); + } + + 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)); + } + } + + public string Version + { + get; + set + { + string newValue = value ?? string.Empty; + if (string.Equals(field, newValue, StringComparison.Ordinal)) + { + return; + } + + field = newValue; + NotifyInputChanged(nameof(Version), nameof(VersionValidationMessage)); + } + } = string.Empty; + + public string ModificationNameValidationMessage => + GetLocalizedValidationMessage(GetModificationNameValidationKey(ModificationName)); + + public string VersionValidationMessage => GetLocalizedValidationMessage(GetVersionValidationKey(Version)); + + public bool CanAccept => + GetModificationNameValidationKey(ModificationName) == null && + GetVersionValidationKey(Version) == null; + + public IAsyncRelayCommand AcceptCommand { get; } + + public IRelayCommand CancelCommand { get; } + + public ManualModificationDialogResult? ImportResult { get; private set; } + + public bool? DialogResult { get; private set; } + + /// + /// Occurs when the view model requests that the owning dialog close. + /// + public event EventHandler? CloseRequested; + + private async Task AcceptAsync() + { + string? validationKey = GetFirstValidationKey(); + if (validationKey != null) + { + await ShowValidationErrorAsync(validationKey); + return; + } + + ImportResult = new ManualModificationDialogResult( + ModificationName, + Version); + CompleteDialog(true); + } + + private void Cancel() + { + CompleteDialog(false); + } + + private void CompleteDialog(bool accepted) + { + DialogResult = accepted; + CloseRequested?.Invoke(this, EventArgs.Empty); + } + + 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); + } + + 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(); + } + + private string? GetFirstValidationKey() + { + return GetModificationNameValidationKey(ModificationName) ?? GetVersionValidationKey(Version); + } + + private static string? GetModificationNameValidationKey(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return MissingModificationNameKey; + } + + return ContainsOnlySupportedFieldCharacters(value) + ? null + : UnsupportedCharactersKey; + } + + private static string? GetVersionValidationKey(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return MissingVersionKey; + } + + if (!value.Any(character => character is >= '0' and <= '9')) + { + return VersionMissingNumberKey; + } + + return ContainsOnlySupportedFieldCharacters(value) + ? null + : UnsupportedCharactersKey; + } + + private string GetLocalizedValidationMessage(string? validationKey) + { + return validationKey == null ? string.Empty : _stringLocalizer[validationKey]; + } + + private static bool ContainsOnlySupportedFieldCharacters(string value) + { + return value.All(IsSupportedFieldCharacter); + } + + private static bool IsSupportedFieldCharacter(char character) + { + return char.IsLetterOrDigit(character) || + character == '_' || + character == '.' || + character == '@' || + character == '-' || + character == ' '; + } + + private Task ShowValidationErrorAsync(string detailKey) + { + return _dialogService.ShowErrorAsync(new LauncherInfoDialogRequest( + _stringLocalizer["OperationAborted"], + _stringLocalizer[detailKey])); + } + + private void NotifyInputChanged(string fieldPropertyName, string validationPropertyName) + { + OnPropertyChanged(fieldPropertyName); + OnPropertyChanged(validationPropertyName); + OnPropertyChanged(nameof(CanAccept)); + AcceptCommand.NotifyCanExecuteChanged(); + } +} diff --git a/GenLauncherGO.UI/Features/Mods/ViewModels/ModificationTileImageProvider.cs b/GenLauncherGO.UI/Features/Mods/ViewModels/ModificationTileImageProvider.cs new file mode 100644 index 00000000..e6581074 --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/ViewModels/ModificationTileImageProvider.cs @@ -0,0 +1,249 @@ +using System; +using System.Globalization; +using System.IO; +using Avalonia.Media; +using Avalonia.Media.Imaging; +using GenLauncherGO.Core.Mods.Contracts; +using GenLauncherGO.Core.Mods.Models; +using GenLauncherGO.UI.Features.Startup; +using Microsoft.Extensions.Logging; + +namespace GenLauncherGO.UI.Features.Mods.ViewModels; + +/// +/// Loads Avalonia image sources for one modification tile. +/// +internal sealed class ModificationTileImageProvider +{ + private readonly ModificationImageSourceFactory _imageSourceFactory; + + private readonly LauncherRuntimeContext _launcherContext; + + private readonly ILogger _logger; + + private readonly IModificationImageFileService _modificationImageFileService; + + private int _advertisingImageIndex = -1; + + public ModificationTileImageProvider( + ModificationImageSourceFactory imageSourceFactory, + LauncherRuntimeContext launcherContext, + IModificationImageFileService modificationImageFileService, + ILogger logger) + { + _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)); + } + + /// + /// Loads the cached shell artwork a version published with its palette, if it published one and it downloaded. + /// + /// + /// Returns when the modification declared no artwork or the download has not landed yet, + /// which the caller treats as "keep the active game's artwork" rather than as a failure to theme. + /// + public IImageBrush? LoadThemeBackground(LauncherContent modification, LauncherContentVersion version) + { + ArgumentNullException.ThrowIfNull(modification); + ArgumentNullException.ThrowIfNull(version); + + if (version.Theme is null || version.Theme.GenLauncherBackgroundImageLink.Length == 0) + { + return null; + } + + string? imagePath = _modificationImageFileService.FindExistingImageFilePath( + modification.ModificationType, + modification.Name, + LauncherContentTheme.ResolveBackgroundImageBaseName(version.Version)); + Bitmap? background = _imageSourceFactory.LoadFileImage(imagePath, false); + if (background is null) + { + return null; + } + + return (IImageBrush)new ImageBrush(background) + { + Stretch = Stretch.Fill + }.ToImmutable(); + } + + /// + /// Loads the grayscale image for a modification tile. + /// + public IImage? LoadGrayscaleImage( + LauncherContent modification, + LauncherContentVersion latestVersion, + bool localMod) + { + if (modification.ModificationType == ModificationType.Mod) + { + return LoadImage( + GetModificationImageFileName(modification, latestVersion, localMod), + modification.ModificationType, + modification.Name, + latestVersion.Version, + true, + true); + } + + if (modification.ModificationType == ModificationType.Advertising) + { + return LoadAdvertisingImage(modification); + } + + return null; + } + + /// + /// Loads the color image for a selected modification tile. + /// + public IImage? LoadColorImage( + LauncherContent modification, + LauncherContentVersion latestVersion, + bool localMod) + { + if (modification.ModificationType != ModificationType.Mod) + { + return null; + } + + return LoadImage( + GetModificationImageFileName(modification, latestVersion, localMod), + modification.ModificationType, + modification.Name, + latestVersion.Version, + false, + true); + } + + private IImage? LoadAdvertisingImage(LauncherContent modification) + { + int filesCount = _modificationImageFileService.CountImageFiles( + modification.ModificationType, + modification.Name); + 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 imageBaseName = _advertisingImageIndex.ToString(CultureInfo.InvariantCulture); + string? imageFileName = _modificationImageFileService.FindExistingImageFilePath( + modification.ModificationType, + modification.Name, + imageBaseName); + + return LoadImage( + imageFileName, + modification.ModificationType, + modification.Name, + imageBaseName, + false, + false); + } + + private string? GetModificationImageFileName( + LauncherContent modification, + LauncherContentVersion latestVersion, + bool localMod) + { + string? imageFileName = _modificationImageFileService.FindExistingImageFilePath( + modification.ModificationType, + modification.Name, + latestVersion.Version); + + if (localMod && !_modificationImageFileService.ImageExists(imageFileName)) + { + return null; + } + + return imageFileName; + } + + /// + /// Loads a cached or default image for a modification tile. + /// + private IImage? LoadImage( + string? path, + ModificationType modificationType, + string modificationName, + string imageBaseName, + bool grayscale, + bool useDefaultWhenMissing) + { + try + { + Bitmap? image = _modificationImageFileService.ImageExists(path) + ? _imageSourceFactory.LoadFileImage(path, grayscale) + : null; + + if (image == null && useDefaultWhenMissing) + { + image = _imageSourceFactory.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, + modificationType, + modificationName, + imageBaseName, + grayscale, + useDefaultWhenMissing); + } + } + + /// + /// Removes an invalid cached image and falls back to the default image when appropriate. + /// + private IImage? TryRemoveInvalidImage( + string? path, + ModificationType modificationType, + string modificationName, + string imageBaseName, + bool grayscale, + bool useDefaultWhenMissing) + { + try + { + _modificationImageFileService.TryDeleteImage( + modificationType, + modificationName, + imageBaseName); + + return useDefaultWhenMissing + ? _imageSourceFactory.LoadDefaultImage(_launcherContext.CurrentlyManagedGame, grayscale) + : null; + } + catch (Exception deleteException) + { + _logger.LogWarning( + deleteException, + "Could not remove invalid modification image {ImageFileName}.", + Path.GetFileName(path)); + return null; + } + } +} diff --git a/GenLauncherGO.UI/Features/Mods/Views/AddModificationWindow.axaml b/GenLauncherGO.UI/Features/Mods/Views/AddModificationWindow.axaml new file mode 100644 index 00000000..a34399b2 --- /dev/null +++ b/GenLauncherGO.UI/Features/Mods/Views/AddModificationWindow.axaml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - -