diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..b0b59f7
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,1744 @@
+
+[*]
+charset = utf-8
+end_of_line = lf
+trim_trailing_whitespace = false
+insert_final_newline = false
+indent_style = space
+indent_size = 4 # A property with the same name was updated with a value 2 in a section [{*.yaml,*.yml}]
+
+[{*.yaml,*.yml}]
+indent_style = space
+indent_size = 2 # A property with the same name was updated with a value 4 in a section [*]; with a value 4 in a section [*.{appxmanifest,asax,ascx,aspx,axaml,build,cg,cginc,compute,cs,cshtml,dtd,hlsl,hlsli,hlslinc,master,nuspec,paml,razor,resw,resx,skin,usf,ush,vb,xaml,xamlx,xoml,xsd}]
+
+[*.{appxmanifest,asax,ascx,aspx,axaml,build,cg,cginc,compute,cs,cshtml,dtd,hlsl,hlsli,hlslinc,master,nuspec,paml,razor,resw,resx,skin,usf,ush,vb,xaml,xamlx,xoml,xsd}]
+indent_style = space
+indent_size = 4 # A property with the same name was updated with a value 2 in a section [{*.yaml,*.yml}]
+tab_width = 4
+
+[*.cs]
+
+#### .NET Coding Conventions ####
+
+# Organize usings
+dotnet_separate_import_directive_groups = false
+dotnet_sort_system_directives_first = false
+file_header_template = unset
+
+# this. and Me. preferences
+dotnet_style_qualification_for_event = false
+dotnet_style_qualification_for_field = false
+dotnet_style_qualification_for_method = false
+dotnet_style_qualification_for_property = false
+
+# Language keywords vs BCL types preferences
+dotnet_style_predefined_type_for_locals_parameters_members = true:suggestion
+dotnet_style_predefined_type_for_member_access = true:suggestion
+
+# Parentheses preferences
+dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:warning
+dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:warning
+dotnet_style_parentheses_in_other_operators = never_if_unnecessary
+dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:warning
+
+# Modifier preferences
+dotnet_style_require_accessibility_modifiers = always:error
+
+# Expression-level preferences
+dotnet_style_coalesce_expression = true
+dotnet_style_collection_initializer = true
+dotnet_style_explicit_tuple_names = true
+dotnet_style_namespace_match_folder = true
+dotnet_style_null_propagation = true
+dotnet_style_object_initializer = true
+dotnet_style_operator_placement_when_wrapping = beginning_of_line
+dotnet_style_prefer_auto_properties = true:warning
+dotnet_style_prefer_compound_assignment = true
+dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion
+dotnet_style_prefer_conditional_expression_over_return = true:suggestion
+dotnet_style_prefer_inferred_anonymous_type_member_names = true
+dotnet_style_prefer_inferred_tuple_names = true
+dotnet_style_prefer_is_null_check_over_reference_equality_method = true:error
+dotnet_style_prefer_simplified_boolean_expressions = true
+dotnet_style_prefer_simplified_interpolation = true
+
+# Field preferences
+dotnet_style_readonly_field = true:suggestion
+
+# Parameter preferences
+dotnet_code_quality_unused_parameters = all:warning
+
+# Suppression preferences
+dotnet_remove_unnecessary_suppression_exclusions = none
+
+# New line preferences
+dotnet_style_allow_multiple_blank_lines_experimental = false
+dotnet_style_allow_statement_immediately_after_block_experimental = false
+
+
+#### C# Coding Conventions ####
+
+# var preferences
+csharp_style_var_elsewhere = true:error
+csharp_style_var_for_built_in_types = true:error
+csharp_style_var_when_type_is_apparent = true:error
+
+# Expression-bodied members
+csharp_style_expression_bodied_accessors = true:suggestion
+csharp_style_expression_bodied_constructors = when_on_single_line:suggestion
+csharp_style_expression_bodied_indexers = true:suggestion
+csharp_style_expression_bodied_lambdas = true:suggestion
+csharp_style_expression_bodied_local_functions = true:suggestion
+csharp_style_expression_bodied_methods = when_on_single_line:suggestion
+csharp_style_expression_bodied_operators = when_on_single_line:suggestion
+csharp_style_expression_bodied_properties = true:suggestion
+
+# Pattern matching preferences
+csharp_style_pattern_matching_over_as_with_null_check = true:error
+csharp_style_pattern_matching_over_is_with_cast_check = true:error
+csharp_style_prefer_not_pattern = true:error
+csharp_style_prefer_pattern_matching = true:suggestion
+csharp_style_prefer_switch_expression = true
+
+# Null-checking preferences
+csharp_style_conditional_delegate_call = true:error
+
+# Modifier preferences
+csharp_prefer_static_local_function = true
+csharp_preferred_modifier_order = public, private, protected, internal, static, extern, new, virtual, abstract, sealed, override, readonly, unsafe, volatile, async
+
+# Code-block preferences
+csharp_prefer_braces = when_multiline:suggestion
+csharp_prefer_simple_using_statement = true
+
+# Expression-level preferences
+csharp_prefer_simple_default_expression = true
+csharp_style_deconstructed_variable_declaration = true
+csharp_style_implicit_object_creation_when_type_is_apparent = true:error
+csharp_style_inlined_variable_declaration = true:warning
+csharp_style_pattern_local_over_anonymous_function = true
+csharp_style_prefer_index_operator = true
+csharp_style_prefer_range_operator = true
+csharp_style_throw_expression = true:error
+csharp_style_unused_value_assignment_preference = discard_variable:warning
+csharp_style_unused_value_expression_statement_preference = discard_variable
+
+# 'using' directive preferences
+csharp_using_directive_placement = outside_namespace:error
+
+# Enforce file-scoped namespaces
+csharp_style_namespace_declarations = file_scoped:error
+
+# New line preferences
+csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true
+csharp_style_allow_blank_lines_between_consecutive_braces_experimental = false
+csharp_style_allow_embedded_statements_on_same_line_experimental = true
+
+
+#### C# Formatting Rules ####
+
+# New line preferences
+csharp_new_line_before_catch = true
+csharp_new_line_before_else = true
+csharp_new_line_before_finally = true
+csharp_new_line_before_members_in_anonymous_types = true
+csharp_new_line_before_members_in_object_initializers = true
+csharp_new_line_before_open_brace = all
+csharp_new_line_between_query_expression_clauses = true
+
+# Indentation preferences
+csharp_indent_block_contents = true
+csharp_indent_braces = false
+csharp_indent_case_contents = true
+csharp_indent_case_contents_when_block = true
+csharp_indent_labels = one_less_than_current
+csharp_indent_switch_labels = true
+
+# Space preferences
+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
+
+# Wrapping preferences
+csharp_preserve_single_line_blocks = true
+csharp_preserve_single_line_statements = true
+
+#### Naming styles ####
+
+# Naming rules
+
+dotnet_naming_rule.class_should_be_pascal_case.import_to_resharper = as_predefined
+dotnet_naming_rule.class_should_be_pascal_case.severity = error
+dotnet_naming_rule.class_should_be_pascal_case.symbols = class
+dotnet_naming_rule.class_should_be_pascal_case.style = pascal_case
+
+dotnet_naming_rule.struct_should_be_pascal_case.import_to_resharper = as_predefined
+dotnet_naming_rule.struct_should_be_pascal_case.severity = error
+dotnet_naming_rule.struct_should_be_pascal_case.symbols = struct
+dotnet_naming_rule.struct_should_be_pascal_case.style = pascal_case
+
+dotnet_naming_rule.interface_should_be_begins_with_i.import_to_resharper = as_predefined
+dotnet_naming_rule.interface_should_be_begins_with_i.severity = error
+dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface
+dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i
+
+dotnet_naming_rule.types_should_be_pascal_case.import_to_resharper = as_predefined
+dotnet_naming_rule.types_should_be_pascal_case.severity = error
+dotnet_naming_rule.types_should_be_pascal_case.symbols = types
+dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case
+
+dotnet_naming_rule.enum_should_be_pascal_case.import_to_resharper = as_predefined
+dotnet_naming_rule.enum_should_be_pascal_case.severity = error
+dotnet_naming_rule.enum_should_be_pascal_case.symbols = enum
+dotnet_naming_rule.enum_should_be_pascal_case.style = pascal_case
+
+dotnet_naming_rule.property_should_be_pascal_case.import_to_resharper = as_predefined
+dotnet_naming_rule.property_should_be_pascal_case.severity = error
+dotnet_naming_rule.property_should_be_pascal_case.symbols = property
+dotnet_naming_rule.property_should_be_pascal_case.style = pascal_case
+
+dotnet_naming_rule.method_should_be_pascal_case.import_to_resharper = as_predefined
+dotnet_naming_rule.method_should_be_pascal_case.severity = error
+dotnet_naming_rule.method_should_be_pascal_case.symbols = method
+dotnet_naming_rule.method_should_be_pascal_case.style = pascal_case
+
+dotnet_naming_rule.async_method_should_be_ends_with_async.import_to_resharper = as_predefined
+dotnet_naming_rule.async_method_should_be_ends_with_async.severity = error
+dotnet_naming_rule.async_method_should_be_ends_with_async.symbols = async_method
+dotnet_naming_rule.async_method_should_be_ends_with_async.style = ends_with_async
+
+dotnet_naming_rule.private_field_should_be_begins_with_underscore.import_to_resharper = as_predefined
+dotnet_naming_rule.private_field_should_be_begins_with_underscore.severity = error
+dotnet_naming_rule.private_field_should_be_begins_with_underscore.symbols = private_field
+dotnet_naming_rule.private_field_should_be_begins_with_underscore.style = begins_with_underscore
+
+dotnet_naming_rule.non_field_members_should_be_pascal_case.import_to_resharper = as_predefined
+dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = error
+dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members
+dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case
+
+dotnet_naming_rule.local_variable_should_be_camel_case.import_to_resharper = as_predefined
+dotnet_naming_rule.local_variable_should_be_camel_case.severity = error
+dotnet_naming_rule.local_variable_should_be_camel_case.symbols = local_variable
+dotnet_naming_rule.local_variable_should_be_camel_case.style = camel_case
+
+dotnet_naming_rule.public_anything_should_be_pascal_case.import_to_resharper = as_predefined
+dotnet_naming_rule.public_anything_should_be_pascal_case.severity = error
+dotnet_naming_rule.public_anything_should_be_pascal_case.symbols = public_anything
+dotnet_naming_rule.public_anything_should_be_pascal_case.style = pascal_case
+
+dotnet_naming_rule.constants_rule.import_to_resharper = as_predefined
+dotnet_naming_rule.constants_rule.severity = error
+dotnet_naming_rule.constants_rule.style = upper_camel_case_style
+dotnet_naming_rule.constants_rule.symbols = constants_symbols
+
+dotnet_naming_rule.event_rule.import_to_resharper = as_predefined
+dotnet_naming_rule.event_rule.severity = error
+dotnet_naming_rule.event_rule.style = upper_camel_case_style
+dotnet_naming_rule.event_rule.symbols = event_symbols
+
+dotnet_naming_rule.interfaces_rule.import_to_resharper = as_predefined
+dotnet_naming_rule.interfaces_rule.severity = error
+dotnet_naming_rule.interfaces_rule.style = i_upper_camel_case_style
+dotnet_naming_rule.interfaces_rule.symbols = interfaces_symbols
+
+dotnet_naming_rule.locals_rule.import_to_resharper = as_predefined
+dotnet_naming_rule.locals_rule.severity = error
+dotnet_naming_rule.locals_rule.style = lower_camel_case_style_1
+dotnet_naming_rule.locals_rule.symbols = locals_symbols
+
+dotnet_naming_rule.local_constants_rule.import_to_resharper = as_predefined
+dotnet_naming_rule.local_constants_rule.severity = error
+dotnet_naming_rule.local_constants_rule.style = lower_camel_case_style_1
+dotnet_naming_rule.local_constants_rule.symbols = local_constants_symbols
+
+dotnet_naming_rule.local_functions_rule.import_to_resharper = as_predefined
+dotnet_naming_rule.local_functions_rule.severity = error
+dotnet_naming_rule.local_functions_rule.style = upper_camel_case_style
+dotnet_naming_rule.local_functions_rule.symbols = local_functions_symbols
+
+dotnet_naming_rule.method_rule.import_to_resharper = as_predefined
+dotnet_naming_rule.method_rule.severity = error
+dotnet_naming_rule.method_rule.style = upper_camel_case_style
+dotnet_naming_rule.method_rule.symbols = method_symbols
+
+dotnet_naming_rule.parameters_rule.import_to_resharper = as_predefined
+dotnet_naming_rule.parameters_rule.severity = error
+dotnet_naming_rule.parameters_rule.style = lower_camel_case_style_1
+dotnet_naming_rule.parameters_rule.symbols = parameters_symbols
+
+dotnet_naming_rule.private_constants_rule.import_to_resharper = as_predefined
+dotnet_naming_rule.private_constants_rule.severity = error
+dotnet_naming_rule.private_constants_rule.style = lower_camel_case_style
+dotnet_naming_rule.private_constants_rule.symbols = private_constants_symbols
+
+dotnet_naming_rule.private_instance_fields_rule.import_to_resharper = as_predefined
+dotnet_naming_rule.private_instance_fields_rule.severity = error
+dotnet_naming_rule.private_instance_fields_rule.style = lower_camel_case_style
+dotnet_naming_rule.private_instance_fields_rule.symbols = private_instance_fields_symbols
+
+dotnet_naming_rule.private_static_fields_rule.import_to_resharper = as_predefined
+dotnet_naming_rule.private_static_fields_rule.severity = error
+dotnet_naming_rule.private_static_fields_rule.style = lower_camel_case_style
+dotnet_naming_rule.private_static_fields_rule.symbols = private_static_fields_symbols
+
+dotnet_naming_rule.private_static_readonly_rule.import_to_resharper = as_predefined
+dotnet_naming_rule.private_static_readonly_rule.severity = error
+dotnet_naming_rule.private_static_readonly_rule.style = lower_camel_case_style
+dotnet_naming_rule.private_static_readonly_rule.symbols = private_static_readonly_symbols
+
+dotnet_naming_rule.property_rule.import_to_resharper = as_predefined
+dotnet_naming_rule.property_rule.severity = error
+dotnet_naming_rule.property_rule.style = upper_camel_case_style
+dotnet_naming_rule.property_rule.symbols = property_symbols
+
+dotnet_naming_rule.public_fields_rule.import_to_resharper = as_predefined
+dotnet_naming_rule.public_fields_rule.severity = error
+dotnet_naming_rule.public_fields_rule.style = upper_camel_case_style
+dotnet_naming_rule.public_fields_rule.symbols = public_fields_symbols
+
+dotnet_naming_rule.public_fields_rule.import_to_resharper = as_predefined
+dotnet_naming_rule.public_fields_rule.severity = error
+dotnet_naming_rule.public_fields_rule.style = camel_case
+dotnet_naming_rule.public_fields_rule.symbols = protected_fields_symbols
+
+dotnet_naming_rule.static_readonly_rule.import_to_resharper = as_predefined
+dotnet_naming_rule.static_readonly_rule.severity = error
+dotnet_naming_rule.static_readonly_rule.style = upper_camel_case_style
+dotnet_naming_rule.static_readonly_rule.symbols = static_readonly_symbols
+
+dotnet_naming_rule.types_and_namespaces_rule.import_to_resharper = as_predefined
+dotnet_naming_rule.types_and_namespaces_rule.severity = error
+dotnet_naming_rule.types_and_namespaces_rule.style = upper_camel_case_style
+dotnet_naming_rule.types_and_namespaces_rule.symbols = types_and_namespaces_symbols
+
+dotnet_naming_rule.type_parameters_rule.import_to_resharper = as_predefined
+dotnet_naming_rule.type_parameters_rule.severity = error
+dotnet_naming_rule.type_parameters_rule.style = t_upper_camel_case_style
+dotnet_naming_rule.type_parameters_rule.symbols = type_parameters_symbols
+
+# Symbol specifications
+
+dotnet_naming_symbols.class.applicable_kinds = class
+dotnet_naming_symbols.class.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.class.required_modifiers =
+
+dotnet_naming_symbols.interface.applicable_kinds = interface
+dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.interface.required_modifiers =
+
+dotnet_naming_symbols.struct.applicable_kinds = struct
+dotnet_naming_symbols.struct.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.struct.required_modifiers =
+
+dotnet_naming_symbols.enum.applicable_kinds = enum
+dotnet_naming_symbols.enum.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.enum.required_modifiers =
+
+dotnet_naming_symbols.method.applicable_kinds = method
+dotnet_naming_symbols.method.applicable_accessibilities = public
+dotnet_naming_symbols.method.required_modifiers =
+
+dotnet_naming_symbols.property.applicable_kinds = property
+dotnet_naming_symbols.property.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.property.required_modifiers =
+
+dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum
+dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.types.required_modifiers =
+
+dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method
+dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected
+dotnet_naming_symbols.non_field_members.required_modifiers =
+
+dotnet_naming_symbols.private_field.applicable_kinds = field
+dotnet_naming_symbols.private_field.applicable_accessibilities = private
+dotnet_naming_symbols.private_field.required_modifiers =
+
+dotnet_naming_symbols.async_method.applicable_kinds = method, local_function
+dotnet_naming_symbols.async_method.applicable_accessibilities = *
+dotnet_naming_symbols.async_method.required_modifiers = async
+
+dotnet_naming_symbols.local_variable.applicable_kinds = parameter, local
+dotnet_naming_symbols.local_variable.applicable_accessibilities = local
+dotnet_naming_symbols.local_variable.required_modifiers =
+
+dotnet_naming_symbols.public_anything.applicable_kinds = property, field, event, class, struct, interface, enum, delegate, method
+dotnet_naming_symbols.public_anything.applicable_accessibilities = public, internal
+dotnet_naming_symbols.public_anything.required_modifiers =
+
+# Naming styles
+
+dotnet_naming_style.pascal_case.required_prefix =
+dotnet_naming_style.pascal_case.required_suffix =
+dotnet_naming_style.pascal_case.word_separator =
+dotnet_naming_style.pascal_case.capitalization = pascal_case
+
+dotnet_naming_style.begins_with_i.required_prefix = I
+dotnet_naming_style.begins_with_i.required_suffix =
+dotnet_naming_style.begins_with_i.word_separator =
+dotnet_naming_style.begins_with_i.capitalization = pascal_case
+
+dotnet_naming_style.begins_with_underscore.required_prefix = _
+dotnet_naming_style.begins_with_underscore.required_suffix =
+dotnet_naming_style.begins_with_underscore.word_separator =
+dotnet_naming_style.begins_with_underscore.capitalization = camel_case
+
+dotnet_naming_style.ends_with_async.required_prefix =
+dotnet_naming_style.ends_with_async.required_suffix = Async
+dotnet_naming_style.ends_with_async.word_separator =
+dotnet_naming_style.ends_with_async.capitalization = pascal_case
+
+dotnet_naming_style.camel_case.required_prefix =
+dotnet_naming_style.camel_case.required_suffix =
+dotnet_naming_style.camel_case.word_separator =
+dotnet_naming_style.camel_case.capitalization = camel_case
+
+dotnet_naming_style.i_upper_camel_case_style.capitalization = pascal_case
+dotnet_naming_style.i_upper_camel_case_style.required_prefix = I
+
+dotnet_naming_style.lower_camel_case_style.capitalization = camel_case
+dotnet_naming_style.lower_camel_case_style.required_prefix = _
+dotnet_naming_style.lower_camel_case_style_1.capitalization = camel_case
+
+dotnet_naming_style.t_upper_camel_case_style.capitalization = pascal_case
+dotnet_naming_style.t_upper_camel_case_style.required_prefix = T
+
+dotnet_naming_style.upper_camel_case_style.capitalization = pascal_case
+
+dotnet_naming_symbols.constants_symbols.applicable_accessibilities = public,internal,protected,protected_internal,private_protected
+dotnet_naming_symbols.constants_symbols.applicable_kinds = field
+dotnet_naming_symbols.constants_symbols.required_modifiers = const
+
+dotnet_naming_symbols.event_symbols.applicable_accessibilities = *
+dotnet_naming_symbols.event_symbols.applicable_kinds = event
+
+dotnet_naming_symbols.interfaces_symbols.applicable_accessibilities = *
+dotnet_naming_symbols.interfaces_symbols.applicable_kinds = interface
+
+dotnet_naming_symbols.locals_symbols.applicable_accessibilities = *
+dotnet_naming_symbols.locals_symbols.applicable_kinds = local
+
+dotnet_naming_symbols.local_constants_symbols.applicable_accessibilities = *
+dotnet_naming_symbols.local_constants_symbols.applicable_kinds = local
+dotnet_naming_symbols.local_constants_symbols.required_modifiers = const
+dotnet_naming_symbols.local_functions_symbols.applicable_accessibilities = *
+dotnet_naming_symbols.local_functions_symbols.applicable_kinds = local_function
+
+dotnet_naming_symbols.method_symbols.applicable_accessibilities = *
+dotnet_naming_symbols.method_symbols.applicable_kinds = method
+
+dotnet_naming_symbols.parameters_symbols.applicable_accessibilities = *
+dotnet_naming_symbols.parameters_symbols.applicable_kinds = parameter
+
+dotnet_naming_symbols.private_constants_symbols.applicable_accessibilities = private
+dotnet_naming_symbols.private_constants_symbols.applicable_kinds = field
+dotnet_naming_symbols.private_constants_symbols.required_modifiers = const
+
+dotnet_naming_symbols.private_instance_fields_symbols.applicable_accessibilities = private
+dotnet_naming_symbols.private_instance_fields_symbols.applicable_kinds = field
+
+dotnet_naming_symbols.private_static_fields_symbols.applicable_accessibilities = private
+dotnet_naming_symbols.private_static_fields_symbols.applicable_kinds = field
+dotnet_naming_symbols.private_static_fields_symbols.required_modifiers = static
+
+dotnet_naming_symbols.private_static_readonly_symbols.applicable_accessibilities = private
+dotnet_naming_symbols.private_static_readonly_symbols.applicable_kinds = field
+dotnet_naming_symbols.private_static_readonly_symbols.required_modifiers = static,readonly
+
+dotnet_naming_symbols.property_symbols.applicable_accessibilities = *
+dotnet_naming_symbols.property_symbols.applicable_kinds = property
+
+dotnet_naming_symbols.public_fields_symbols.applicable_accessibilities = public,internal,protected,protected_internal,private_protected
+dotnet_naming_symbols.public_fields_symbols.applicable_kinds = field
+
+dotnet_naming_symbols.static_readonly_symbols.applicable_accessibilities = public,internal,protected,protected_internal,private_protected
+dotnet_naming_symbols.static_readonly_symbols.applicable_kinds = field
+dotnet_naming_symbols.static_readonly_symbols.required_modifiers = static,readonly
+
+dotnet_naming_symbols.types_and_namespaces_symbols.applicable_accessibilities = *
+dotnet_naming_symbols.types_and_namespaces_symbols.applicable_kinds = namespace,class,struct,enum,delegate
+
+dotnet_naming_symbols.type_parameters_symbols.applicable_accessibilities = *
+dotnet_naming_symbols.type_parameters_symbols.applicable_kinds = type_parameter
+
+
+#### Visual Studio Specific Rules ####
+
+# CA1822: Mark members as static
+dotnet_diagnostic.CA1822.severity = none
+
+# IDE0004: Cast is redundant
+dotnet_diagnostic.IDE0004.severity = error
+
+# IDE0058: Expression value is never used
+dotnet_diagnostic.IDE0058.severity = none
+
+# IDE0011: Add braces to 'if'/'else' statement
+dotnet_diagnostic.IDE0011.severity = none
+
+# IDE0290: Use primary constructor
+dotnet_diagnostic.IDE0290.severity = none
+
+#### ReSharper Properties ####
+
+resharper_accessor_owner_body = expression_body
+resharper_alignment_tab_fill_style = use_spaces
+resharper_align_first_arg_by_paren = false
+resharper_align_linq_query = false
+resharper_align_multiline_array_and_object_initializer = false
+resharper_align_multiline_array_initializer = true
+resharper_align_multiline_binary_expressions_chain = false
+resharper_align_multiline_binary_patterns = false
+resharper_align_multiline_ctor_init = true
+resharper_align_multiline_expression_braces = false
+resharper_align_multiline_implements_list = true
+resharper_align_multiline_property_pattern = false
+resharper_align_multiline_statement_conditions = true
+resharper_align_multiline_switch_expression = false
+resharper_align_multiline_type_argument = true
+resharper_align_multiline_type_parameter = true
+resharper_align_multline_type_parameter_constrains = true
+resharper_align_multline_type_parameter_list = false
+resharper_align_ternary = align_not_nested
+resharper_align_tuple_components = false
+resharper_allow_alias = true
+resharper_allow_comment_after_lbrace = false
+resharper_allow_far_alignment = false
+resharper_always_use_end_of_line_brace_style = false
+resharper_apply_auto_detected_rules = true
+resharper_apply_on_completion = false
+resharper_arguments_anonymous_function = positional
+resharper_arguments_literal = positional
+resharper_arguments_named = positional
+resharper_arguments_other = positional
+resharper_arguments_skip_single = false
+resharper_arguments_string_literal = positional
+resharper_attribute_style = do_not_touch
+resharper_autodetect_indent_settings = true
+resharper_blank_lines_after_block_statements = 1
+resharper_blank_lines_after_case = 0
+resharper_blank_lines_after_control_transfer_statements = 1
+resharper_blank_lines_after_file_scoped_namespace_directive = 1
+resharper_blank_lines_after_imports = 1
+resharper_blank_lines_after_multiline_statements = 1
+resharper_blank_lines_after_options = 1
+resharper_blank_lines_after_start_comment = 1
+resharper_blank_lines_after_using_list = 1
+resharper_blank_lines_around_accessor = 0
+resharper_blank_lines_around_auto_property = 1
+resharper_blank_lines_around_block_case_section = 0
+resharper_blank_lines_around_class_definition = 1
+resharper_blank_lines_around_field = 1
+resharper_blank_lines_around_function_declaration = 0
+resharper_blank_lines_around_function_definition = 1
+resharper_blank_lines_around_global_attribute = 0
+resharper_blank_lines_around_invocable = 1
+resharper_blank_lines_around_local_method = 1
+resharper_blank_lines_around_multiline_case_section = 1
+resharper_blank_lines_around_namespace = 1
+resharper_blank_lines_around_other_declaration = 0
+resharper_blank_lines_around_property = 1
+resharper_blank_lines_around_razor_functions = 1
+resharper_blank_lines_around_razor_helpers = 1
+resharper_blank_lines_around_razor_sections = 1
+resharper_blank_lines_around_region = 1
+resharper_blank_lines_around_single_line_accessor = 0
+resharper_blank_lines_around_single_line_auto_property = 0
+resharper_blank_lines_around_single_line_field = 0
+resharper_blank_lines_around_single_line_function_definition = 0
+resharper_blank_lines_around_single_line_invocable = 0
+resharper_blank_lines_around_single_line_local_method = 0
+resharper_blank_lines_around_single_line_property = 0
+resharper_blank_lines_around_single_line_type = 0
+resharper_blank_lines_around_type = 1
+resharper_blank_lines_before_block_statements = 1
+resharper_blank_lines_before_case = 0
+resharper_blank_lines_before_control_transfer_statements = 1
+resharper_blank_lines_before_multiline_statements = 1
+resharper_blank_lines_before_single_line_comment = 1
+resharper_blank_lines_inside_namespace = 0
+resharper_blank_lines_inside_region = 1
+resharper_blank_lines_inside_type = 0
+resharper_blank_line_after_pi = true
+resharper_braces_for_dowhile = required
+resharper_braces_for_fixed = required
+resharper_braces_for_for = required_for_multiline
+resharper_braces_for_foreach = required_for_multiline
+resharper_braces_for_ifelse = required_for_multiline
+resharper_braces_for_lock = required
+resharper_braces_for_using = required
+resharper_braces_for_while = required_for_multiline
+resharper_braces_redundant = true
+resharper_break_template_declaration = line_break
+resharper_can_use_global_alias = false
+resharper_configure_await_analysis_mode = disabled
+resharper_constructor_or_destructor_body = expression_body
+resharper_continuous_indent_multiplier = 1
+resharper_continuous_line_indent = single
+resharper_csharp_align_multiline_argument = false
+resharper_csharp_align_multiline_calls_chain = false
+resharper_csharp_align_multiline_expression = false
+resharper_csharp_align_multiline_extends_list = false
+resharper_csharp_align_multiline_for_stmt = false
+resharper_csharp_align_multiline_parameter = false
+resharper_csharp_align_multiple_declaration = false
+resharper_csharp_int_align_comments = true
+resharper_csharp_max_line_length = 120
+resharper_csharp_naming_rule.enum_member = AaBb
+resharper_csharp_naming_rule.method_property_event = AaBb
+resharper_csharp_naming_rule.other = AaBb
+resharper_csharp_new_line_before_while = false
+resharper_csharp_prefer_qualified_reference = false
+resharper_csharp_space_after_unary_operator = false
+resharper_csharp_stick_comment = false
+resharper_csharp_wrap_after_declaration_lpar = true
+resharper_csharp_wrap_after_invocation_lpar = true
+resharper_csharp_wrap_arguments_style = chop_if_long
+resharper_csharp_wrap_before_declaration_rpar = true
+resharper_csharp_wrap_before_invocation_rpar = true
+resharper_csharp_wrap_lines = true
+resharper_csharp_wrap_parameters_style = chop_if_long
+resharper_cxxcli_property_declaration_braces = next_line
+resharper_default_exception_variable_name = e
+resharper_default_value_when_type_evident = default_literal
+resharper_default_value_when_type_not_evident = default_literal
+resharper_delete_quotes_from_solid_values = false
+resharper_disable_blank_line_changes = false
+resharper_disable_formatter = false
+resharper_disable_indenter = false
+resharper_disable_int_align = false
+resharper_disable_line_break_changes = false
+resharper_disable_line_break_removal = false
+resharper_disable_space_changes = false
+resharper_disable_space_changes_before_trailing_comment = false
+resharper_dont_remove_extra_blank_lines = false
+resharper_empty_block_style = multiline
+resharper_enable_wrapping = false
+resharper_enforce_line_ending_style = true
+resharper_event_handler_pattern_long = $object$On$event$
+resharper_event_handler_pattern_short = On$event$
+resharper_expression_braces = inside
+resharper_expression_pars = inside
+resharper_extra_spaces = remove_all
+resharper_force_attribute_style = join
+resharper_force_chop_compound_do_expression = false
+resharper_force_chop_compound_if_expression = false
+resharper_force_chop_compound_while_expression = false
+resharper_format_leading_spaces_decl = false
+resharper_free_block_braces = next_line
+resharper_function_declaration_return_type_style = do_not_change
+resharper_function_definition_return_type_style = do_not_change
+resharper_generator_mode = false
+resharper_html_attribute_indent = align_by_first_attribute
+resharper_html_linebreak_before_elements = body,div,p,form,h1,h2,h3
+resharper_html_max_blank_lines_between_tags = 2
+resharper_html_max_line_length = 120
+resharper_html_pi_attribute_style = on_single_line
+resharper_html_space_before_self_closing = false
+resharper_html_wrap_lines = true
+resharper_ignore_space_preservation = false
+resharper_include_prefix_comment_in_indent = false
+resharper_indent_access_specifiers_from_class = false
+resharper_indent_aligned_ternary = true
+resharper_indent_anonymous_method_block = false
+resharper_indent_braces_inside_statement_conditions = true
+resharper_indent_case_from_select = true
+resharper_indent_child_elements = OneIndent
+resharper_indent_class_members_from_access_specifiers = false
+resharper_indent_comment = true
+resharper_indent_inside_namespace = true
+resharper_indent_invocation_pars = inside
+resharper_indent_method_decl_pars = inside
+resharper_indent_nested_fixed_stmt = false
+resharper_indent_nested_foreach_stmt = false
+resharper_indent_nested_for_stmt = false
+resharper_indent_nested_lock_stmt = false
+resharper_indent_nested_usings_stmt = false
+resharper_indent_nested_while_stmt = false
+resharper_indent_pars = inside
+resharper_indent_preprocessor_directives = none
+resharper_indent_preprocessor_if = no_indent
+resharper_indent_preprocessor_other = no_indent
+resharper_indent_preprocessor_region = usual_indent
+resharper_indent_statement_pars = inside
+resharper_indent_text = OneIndent
+resharper_indent_typearg_angles = inside
+resharper_indent_typeparam_angles = inside
+resharper_indent_type_constraints = true
+resharper_indent_wrapped_function_names = false
+resharper_instance_members_qualify_declared_in = base_class
+resharper_int_align_assignments = false
+resharper_int_align_binary_expressions = false
+resharper_int_align_declaration_names = false
+resharper_int_align_eq = false
+resharper_int_align_fields = false
+resharper_int_align_fix_in_adjacent = true
+resharper_int_align_invocations = false
+resharper_int_align_methods = false
+resharper_int_align_nested_ternary = true
+resharper_int_align_parameters = false
+resharper_int_align_properties = false
+resharper_int_align_property_patterns = false
+resharper_int_align_switch_expressions = false
+resharper_int_align_switch_sections = false
+resharper_int_align_variables = false
+resharper_keep_blank_lines_in_code = 2
+resharper_keep_blank_lines_in_declarations = 2
+resharper_keep_existing_attribute_arrangement = false
+resharper_keep_existing_declaration_block_arrangement = false
+resharper_keep_existing_declaration_parens_arrangement = true
+resharper_keep_existing_embedded_arrangement = true
+resharper_keep_existing_embedded_block_arrangement = false
+resharper_keep_existing_enum_arrangement = false
+resharper_keep_existing_expr_member_arrangement = false
+resharper_keep_existing_invocation_parens_arrangement = true
+resharper_keep_existing_property_patterns_arrangement = true
+resharper_keep_existing_switch_expression_arrangement = false
+resharper_keep_nontrivial_alias = true
+resharper_keep_user_linebreaks = true
+resharper_keep_user_wrapping = true
+resharper_linebreaks_around_razor_statements = true
+resharper_linebreaks_inside_tags_for_elements_longer_than = 2147483647
+resharper_linebreaks_inside_tags_for_elements_with_child_elements = true
+resharper_linebreaks_inside_tags_for_multiline_elements = true
+resharper_linebreak_before_all_elements = false
+resharper_linebreak_before_multiline_elements = true
+resharper_linebreak_before_singleline_elements = false
+resharper_line_break_after_colon_in_member_initializer_lists = do_not_change
+resharper_line_break_after_comma_in_member_initializer_lists = false
+resharper_line_break_before_comma_in_member_initializer_lists = false
+resharper_line_break_before_requires_clause = do_not_change
+resharper_linkage_specification_braces = end_of_line
+resharper_linkage_specification_indentation = none
+resharper_local_function_body = expression_body
+resharper_macro_block_begin =
+resharper_macro_block_end =
+resharper_max_array_initializer_elements_on_line = 10000
+resharper_max_attribute_length_for_same_line = 38
+resharper_max_enum_members_on_line = 3
+resharper_max_formal_parameters_on_line = 10000
+resharper_max_initializer_elements_on_line = 5
+resharper_max_invocation_arguments_on_line = 10000
+resharper_member_initializer_list_style = do_not_change
+resharper_method_or_operator_body = expression_body
+resharper_namespace_body = file_scoped
+resharper_namespace_declaration_braces = next_line
+resharper_namespace_indentation = all
+resharper_nested_ternary_style = expanded
+resharper_new_line_before_catch = true
+resharper_new_line_before_else = true
+resharper_new_line_before_enumerators = true
+resharper_normalize_tag_names = false
+resharper_no_indent_inside_elements = html,body,thead,tbody,tfoot
+resharper_no_indent_inside_if_element_longer_than = 200
+resharper_object_creation_when_type_evident = target_typed
+resharper_object_creation_when_type_not_evident = target_typed
+resharper_old_engine = false
+resharper_outdent_binary_ops = false
+resharper_outdent_binary_pattern_ops = false
+resharper_outdent_commas = false
+resharper_outdent_dots = false
+resharper_outdent_namespace_member = false
+resharper_outdent_statement_labels = false
+resharper_outdent_ternary_ops = false
+resharper_parentheses_non_obvious_operations = none, conditional, conditional_or, conditional_and, bitwise, bitwise_inclusive_or, bitwise_exclusive_or, shift, arithmetic, additive, multiplicative, bitwise_and
+resharper_parentheses_redundancy_style = remove_if_not_clarifies_precedence
+resharper_parentheses_same_type_operations = false
+resharper_pi_attributes_indent = align_by_first_attribute
+resharper_place_accessorholder_attribute_on_same_line = false
+resharper_place_accessor_attribute_on_same_line = if_owner_is_single_line
+resharper_place_comments_at_first_column = false
+resharper_place_constructor_initializer_on_same_line = true
+resharper_place_event_attribute_on_same_line = false
+resharper_place_expr_accessor_on_single_line = if_owner_is_single_line
+resharper_place_expr_method_on_single_line = false
+resharper_place_expr_property_on_single_line = false
+resharper_place_field_attribute_on_same_line = true
+resharper_place_linq_into_on_new_line = false
+resharper_place_method_attribute_on_same_line = false
+resharper_place_namespace_definitions_on_same_line = false
+resharper_place_property_attribute_on_same_line = false
+resharper_place_simple_case_statement_on_same_line = false
+resharper_place_simple_embedded_statement_on_same_line = false
+resharper_place_simple_enum_on_single_line = true
+resharper_place_simple_initializer_on_single_line = true
+resharper_place_simple_property_pattern_on_single_line = true
+resharper_place_simple_switch_expression_on_single_line = false
+resharper_place_type_attribute_on_same_line = false
+resharper_place_type_constraints_on_same_line = true
+resharper_prefer_explicit_discard_declaration = false
+resharper_prefer_separate_deconstructed_variables_declaration = true
+resharper_preserve_spaces_inside_tags = pre,textarea
+resharper_qualified_using_at_nested_scope = false
+resharper_quote_style = doublequoted
+resharper_razor_prefer_qualified_reference = true
+resharper_remove_blank_lines_near_braces = false
+resharper_remove_blank_lines_near_braces_in_code = true
+resharper_remove_blank_lines_near_braces_in_declarations = true
+resharper_remove_this_qualifier = true
+resharper_requires_expression_braces = next_line
+resharper_resx_attribute_indent = single_indent
+resharper_resx_linebreak_before_elements =
+resharper_resx_max_blank_lines_between_tags = 0
+resharper_resx_max_line_length = 2147483647
+resharper_resx_pi_attribute_style = do_not_touch
+resharper_resx_space_before_self_closing = false
+resharper_resx_wrap_lines = false
+resharper_resx_wrap_tags_and_pi = false
+resharper_resx_wrap_text = false
+resharper_show_autodetect_configure_formatting_tip = true
+resharper_simple_block_style = do_not_change
+resharper_simple_case_statement_style = do_not_change
+resharper_simple_embedded_statement_style = do_not_change
+resharper_sort_attributes = false
+resharper_sort_class_selectors = false
+resharper_sort_usings = true
+resharper_sort_usings_lowercase_first = false
+resharper_spaces_around_eq_in_attribute = false
+resharper_spaces_around_eq_in_pi_attribute = false
+resharper_spaces_inside_tags = false
+resharper_space_after_attributes = true
+resharper_space_after_attribute_target_colon = true
+resharper_space_after_colon = true
+resharper_space_after_colon_in_case = true
+resharper_space_after_colon_in_inheritance_clause = true
+resharper_space_after_comma = true
+resharper_space_after_for_colon = true
+resharper_space_after_keywords_in_control_flow_statements = true
+resharper_space_after_last_attribute = false
+resharper_space_after_last_pi_attribute = false
+resharper_space_after_operator_keyword = true
+resharper_space_after_ptr_in_data_member = true
+resharper_space_after_ptr_in_data_members = false
+resharper_space_after_ptr_in_method = true
+resharper_space_after_ref_in_data_member = true
+resharper_space_after_ref_in_data_members = false
+resharper_space_after_ref_in_method = true
+resharper_space_after_semicolon_in_for_statement = true
+resharper_space_after_ternary_colon = true
+resharper_space_after_ternary_quest = true
+resharper_space_after_triple_slash = true
+resharper_space_after_type_parameter_constraint_colon = true
+resharper_space_around_additive_op = true
+resharper_space_around_alias_eq = true
+resharper_space_around_assignment_op = true
+resharper_space_around_assignment_operator = true
+resharper_space_around_deref_in_trailing_return_type = true
+resharper_space_around_lambda_arrow = true
+resharper_space_around_member_access_operator = false
+resharper_space_around_relational_op = true
+resharper_space_around_shift_op = true
+resharper_space_around_stmt_colon = true
+resharper_space_around_ternary_operator = true
+resharper_space_before_array_rank_parentheses = false
+resharper_space_before_attribute_target_colon = false
+resharper_space_before_checked_parentheses = false
+resharper_space_before_colon = false
+resharper_space_before_colon_in_case = false
+resharper_space_before_colon_in_inheritance_clause = true
+resharper_space_before_comma = false
+resharper_space_before_default_parentheses = false
+resharper_space_before_empty_invocation_parentheses = false
+resharper_space_before_empty_method_parentheses = false
+resharper_space_before_for_colon = true
+resharper_space_before_initializer_braces = false
+resharper_space_before_invocation_parentheses = false
+resharper_space_before_label_colon = false
+resharper_space_before_lambda_parentheses = false
+resharper_space_before_method_parentheses = false
+resharper_space_before_nameof_parentheses = false
+resharper_space_before_new_parentheses = false
+resharper_space_before_nullable_mark = false
+resharper_space_before_open_square_brackets = false
+resharper_space_before_pointer_asterik_declaration = false
+resharper_space_before_ptr_in_abstract_decl = false
+resharper_space_before_ptr_in_data_member = false
+resharper_space_before_ptr_in_data_members = true
+resharper_space_before_ptr_in_method = false
+resharper_space_before_ref_in_abstract_decl = false
+resharper_space_before_ref_in_data_member = false
+resharper_space_before_ref_in_data_members = true
+resharper_space_before_ref_in_method = false
+resharper_space_before_semicolon = false
+resharper_space_before_semicolon_in_for_statement = false
+resharper_space_before_singleline_accessorholder = true
+resharper_space_before_sizeof_parentheses = false
+resharper_space_before_template_args = false
+resharper_space_before_template_params = true
+resharper_space_before_ternary_colon = true
+resharper_space_before_ternary_quest = true
+resharper_space_before_trailing_comment = true
+resharper_space_before_typeof_parentheses = false
+resharper_space_before_type_argument_angle = false
+resharper_space_before_type_parameter_angle = false
+resharper_space_before_type_parameter_constraint_colon = true
+resharper_space_before_type_parameter_parentheses = true
+resharper_space_between_accessors_in_singleline_property = true
+resharper_space_between_attribute_sections = true
+resharper_space_between_closing_angle_brackets_in_template_args = false
+resharper_space_between_keyword_and_expression = true
+resharper_space_between_keyword_and_type = true
+resharper_space_between_method_call_empty_parameter_list_parentheses = false
+resharper_space_between_method_call_name_and_opening_parenthesis = false
+resharper_space_between_method_call_parameter_list_parentheses = false
+resharper_space_between_method_declaration_empty_parameter_list_parentheses = false
+resharper_space_between_method_declaration_name_and_open_parenthesis = false
+resharper_space_between_method_declaration_parameter_list_parentheses = false
+resharper_space_between_parentheses_of_control_flow_statements = false
+resharper_space_between_square_brackets = false
+resharper_space_between_typecast_parentheses = false
+resharper_space_in_singleline_accessorholder = true
+resharper_space_in_singleline_anonymous_method = true
+resharper_space_in_singleline_method = true
+resharper_space_near_postfix_and_prefix_op = false
+resharper_space_within_array_initialization_braces = false
+resharper_space_within_array_rank_empty_parentheses = false
+resharper_space_within_array_rank_parentheses = false
+resharper_space_within_attribute_angles = false
+resharper_space_within_checked_parentheses = false
+resharper_space_within_default_parentheses = false
+resharper_space_within_empty_braces = true
+resharper_space_within_empty_initializer_braces = false
+resharper_space_within_empty_invocation_parentheses = false
+resharper_space_within_empty_method_parentheses = false
+resharper_space_within_empty_template_params = false
+resharper_space_within_expression_parentheses = false
+resharper_space_within_initializer_braces = false
+resharper_space_within_invocation_parentheses = false
+resharper_space_within_method_parentheses = false
+resharper_space_within_nameof_parentheses = false
+resharper_space_within_new_parentheses = false
+resharper_space_within_parentheses = false
+resharper_space_within_single_line_array_initializer_braces = false
+resharper_space_within_sizeof_parentheses = false
+resharper_space_within_template_args = false
+resharper_space_within_template_params = false
+resharper_space_within_tuple_parentheses = false
+resharper_space_within_typeof_parentheses = false
+resharper_space_within_type_argument_angles = false
+resharper_space_within_type_parameter_angles = false
+resharper_space_within_type_parameter_parentheses = false
+resharper_special_else_if_treatment = true
+resharper_static_members_qualify_members = none
+resharper_static_members_qualify_with = declared_type
+resharper_support_vs_event_naming_pattern = true
+resharper_T4_max_line_length = 120
+resharper_T4_wrap_lines = true
+resharper_toplevel_function_declaration_return_type_style = do_not_change
+resharper_toplevel_function_definition_return_type_style = do_not_change
+resharper_trailing_comma_in_multiline_lists = false
+resharper_trailing_comma_in_singleline_lists = false
+resharper_use_continuous_indent_inside_initializer_braces = true
+resharper_use_continuous_indent_inside_parens = true
+resharper_use_continuous_line_indent_in_expression_braces = false
+resharper_use_continuous_line_indent_in_method_pars = false
+resharper_use_heuristics_for_body_style = true
+resharper_use_indents_from_main_language_in_file = true
+resharper_use_indent_from_previous_element = true
+resharper_use_indent_from_vs = false
+resharper_use_roslyn_logic_for_evident_types = true
+resharper_wrap_after_binary_opsign = true
+resharper_wrap_after_dot = false
+resharper_wrap_after_dot_in_method_calls = false
+resharper_wrap_after_expression_lbrace = true
+resharper_wrap_around_elements = true
+resharper_wrap_array_initializer_style = chop_if_long
+resharper_wrap_base_clause_style = wrap_if_long
+resharper_wrap_before_arrow_with_expressions = true
+resharper_wrap_before_binary_opsign = false
+resharper_wrap_before_binary_pattern_op = true
+resharper_wrap_before_colon = false
+resharper_wrap_before_comma = false
+resharper_wrap_before_comma_in_base_clause = false
+resharper_wrap_before_declaration_lpar = false
+resharper_wrap_before_eq = false
+resharper_wrap_before_expression_rbrace = true
+resharper_wrap_before_extends_colon = false
+resharper_wrap_before_first_type_parameter_constraint = false
+resharper_wrap_before_invocation_lpar = false
+resharper_wrap_before_linq_expression = false
+resharper_wrap_before_ternary_opsigns = true
+resharper_wrap_before_type_parameter_langle = false
+resharper_wrap_braced_init_list_style = wrap_if_long
+resharper_wrap_chained_binary_expressions = chop_if_long
+resharper_wrap_chained_binary_patterns = wrap_if_long
+resharper_wrap_chained_method_calls = chop_if_long
+resharper_wrap_ctor_initializer_style = wrap_if_long
+resharper_wrap_enumeration_style = chop_if_long
+resharper_wrap_enum_declaration = chop_always
+resharper_wrap_extends_list_style = wrap_if_long
+resharper_wrap_for_stmt_header_style = chop_if_long
+resharper_wrap_linq_expressions = chop_always
+resharper_wrap_multiple_declaration_style = chop_if_long
+resharper_wrap_multiple_type_parameter_constraints_style = chop_if_long
+resharper_wrap_object_and_collection_initializer_style = chop_if_long
+resharper_wrap_property_pattern = chop_if_long
+resharper_wrap_switch_expression = chop_always
+resharper_wrap_ternary_expr_style = chop_if_long
+resharper_wrap_verbatim_interpolated_strings = no_wrap
+resharper_xmldoc_attribute_indent = single_indent
+resharper_xmldoc_linebreak_before_elements = summary,remarks,example,returns,param,typeparam,value,para
+resharper_xmldoc_max_blank_lines_between_tags = 0
+resharper_xmldoc_max_line_length = 120
+resharper_xmldoc_pi_attribute_style = do_not_touch
+resharper_xmldoc_space_before_self_closing = true
+resharper_xmldoc_wrap_lines = true
+resharper_xmldoc_wrap_tags_and_pi = true
+resharper_xmldoc_wrap_text = true
+resharper_xml_attribute_indent = align_by_first_attribute
+resharper_xml_linebreak_before_elements =
+resharper_xml_max_blank_lines_between_tags = 2
+resharper_xml_max_line_length = 120
+resharper_xml_pi_attribute_style = do_not_touch
+resharper_xml_space_before_self_closing = true
+resharper_xml_wrap_lines = true
+resharper_xml_wrap_tags_and_pi = true
+resharper_xml_wrap_text = false
+
+# ReSharper inspection severities
+resharper_access_rights_in_text_highlighting = warning
+resharper_access_to_disposed_closure_highlighting = warning
+resharper_access_to_for_each_variable_in_closure_highlighting = warning
+resharper_access_to_modified_closure_highlighting = warning
+resharper_access_to_static_member_via_derived_type_highlighting = warning
+resharper_address_of_marshal_by_ref_object_highlighting = warning
+resharper_angular_html_banana_highlighting = warning
+resharper_annotate_can_be_null_parameter_highlighting = none
+resharper_annotate_can_be_null_type_member_highlighting = none
+resharper_annotate_not_null_parameter_highlighting = none
+resharper_annotate_not_null_type_member_highlighting = none
+resharper_annotation_conflict_in_hierarchy_highlighting = warning
+resharper_annotation_redundancy_at_value_type_highlighting = warning
+resharper_annotation_redundancy_in_hierarchy_highlighting = warning
+resharper_anonymous_object_destructuring_problem_highlighting = warning
+resharper_arguments_style_anonymous_function_highlighting = hint
+resharper_arguments_style_literal_highlighting = hint
+resharper_arguments_style_named_expression_highlighting = hint
+resharper_arguments_style_other_highlighting = hint
+resharper_arguments_style_string_literal_highlighting = hint
+resharper_arrange_accessor_owner_body_highlighting = error
+resharper_arrange_attributes_highlighting = none
+resharper_arrange_constructor_or_destructor_body_highlighting = error
+resharper_arrange_default_value_when_type_evident_highlighting = suggestion
+resharper_arrange_default_value_when_type_not_evident_highlighting = suggestion
+resharper_arrange_local_function_body_highlighting = error
+resharper_arrange_method_or_operator_body_highlighting = error
+resharper_arrange_missing_parentheses_highlighting = hint
+resharper_arrange_namespace_body_highlighting = error
+resharper_arrange_object_creation_when_type_evident_highlighting = suggestion
+resharper_arrange_object_creation_when_type_not_evident_highlighting = suggestion
+resharper_arrange_redundant_parentheses_highlighting = hint
+resharper_arrange_static_member_qualifier_highlighting = hint
+resharper_arrange_trailing_comma_in_multiline_lists_highlighting = hint
+resharper_arrange_trailing_comma_in_singleline_lists_highlighting = hint
+resharper_arrange_var_keywords_in_deconstructing_declaration_highlighting = suggestion
+resharper_assignment_in_conditional_expression_highlighting = warning
+resharper_assignment_is_fully_discarded_highlighting = warning
+resharper_assign_null_to_not_null_attribute_highlighting = warning
+resharper_asxx_path_error_highlighting = warning
+resharper_async_iterator_invocation_without_await_foreach_highlighting = warning
+resharper_async_void_lambda_highlighting = warning
+resharper_async_void_method_highlighting = warning
+resharper_auto_property_can_be_made_get_only_global_highlighting = suggestion
+resharper_auto_property_can_be_made_get_only_local_highlighting = suggestion
+resharper_bad_attribute_brackets_spaces_highlighting = none
+resharper_bad_braces_spaces_highlighting = none
+resharper_bad_child_statement_indent_highlighting = warning
+resharper_bad_colon_spaces_highlighting = none
+resharper_bad_comma_spaces_highlighting = none
+resharper_bad_control_braces_indent_highlighting = suggestion
+resharper_bad_control_braces_line_breaks_highlighting = none
+resharper_bad_declaration_braces_indent_highlighting = none
+resharper_bad_declaration_braces_line_breaks_highlighting = none
+resharper_bad_empty_braces_line_breaks_highlighting = none
+resharper_bad_expression_braces_indent_highlighting = none
+resharper_bad_expression_braces_line_breaks_highlighting = none
+resharper_bad_generic_brackets_spaces_highlighting = none
+resharper_bad_indent_highlighting = none
+resharper_bad_linq_line_breaks_highlighting = none
+resharper_bad_list_line_breaks_highlighting = none
+resharper_bad_member_access_spaces_highlighting = none
+resharper_bad_namespace_braces_indent_highlighting = none
+resharper_bad_parens_line_breaks_highlighting = none
+resharper_bad_parens_spaces_highlighting = none
+resharper_bad_preprocessor_indent_highlighting = none
+resharper_bad_semicolon_spaces_highlighting = none
+resharper_bad_spaces_after_keyword_highlighting = none
+resharper_bad_square_brackets_spaces_highlighting = none
+resharper_bad_switch_braces_indent_highlighting = none
+resharper_bad_symbol_spaces_highlighting = none
+resharper_base_member_has_params_highlighting = warning
+resharper_base_method_call_with_default_parameter_highlighting = warning
+resharper_base_object_equals_is_object_equals_highlighting = warning
+resharper_base_object_get_hash_code_call_in_get_hash_code_highlighting = warning
+resharper_bitwise_operator_on_enum_without_flags_highlighting = warning
+resharper_built_in_type_reference_style_for_member_access_highlighting = hint
+resharper_built_in_type_reference_style_highlighting = hint
+resharper_by_ref_argument_is_volatile_field_highlighting = warning
+resharper_cannot_apply_equality_operator_to_type_highlighting = warning
+resharper_center_tag_is_obsolete_highlighting = warning
+resharper_check_for_reference_equality_instead_1_highlighting = suggestion
+resharper_check_for_reference_equality_instead_2_highlighting = suggestion
+resharper_check_for_reference_equality_instead_3_highlighting = suggestion
+resharper_check_for_reference_equality_instead_4_highlighting = suggestion
+resharper_check_namespace_highlighting = warning
+resharper_class_cannot_be_instantiated_highlighting = warning
+resharper_class_can_be_sealed_global_highlighting = none
+resharper_class_can_be_sealed_local_highlighting = none
+resharper_class_never_instantiated_global_highlighting = none
+resharper_class_never_instantiated_local_highlighting = suggestion
+resharper_class_with_virtual_members_never_inherited_global_highlighting = suggestion
+resharper_class_with_virtual_members_never_inherited_local_highlighting = suggestion
+resharper_clear_attribute_is_obsolete_all_highlighting = warning
+resharper_clear_attribute_is_obsolete_highlighting = warning
+resharper_collection_never_queried_global_highlighting = warning
+resharper_collection_never_queried_local_highlighting = warning
+resharper_collection_never_updated_global_highlighting = warning
+resharper_collection_never_updated_local_highlighting = warning
+resharper_comment_typo_highlighting = suggestion
+resharper_compare_non_constrained_generic_with_null_highlighting = none
+resharper_compare_of_floats_by_equality_operator_highlighting = warning
+resharper_complex_object_destructuring_problem_highlighting = warning
+resharper_complex_object_in_context_destructuring_problem_highlighting = warning
+resharper_conditional_ternary_equal_branch_highlighting = warning
+resharper_condition_is_always_true_or_false_highlighting = warning
+resharper_confusing_char_as_integer_in_constructor_highlighting = warning
+resharper_constant_conditional_access_qualifier_highlighting = warning
+resharper_constant_null_coalescing_condition_highlighting = warning
+resharper_constructor_initializer_loop_highlighting = warning
+resharper_container_annotation_redundancy_highlighting = warning
+resharper_contextual_logger_problem_highlighting = warning
+resharper_context_value_is_provided_highlighting = none
+resharper_contract_annotation_not_parsed_highlighting = warning
+resharper_convert_closure_to_method_group_highlighting = suggestion
+resharper_convert_conditional_ternary_expression_to_switch_expression_highlighting = hint
+resharper_convert_if_do_to_while_highlighting = suggestion
+resharper_convert_if_statement_to_conditional_ternary_expression_highlighting = suggestion
+resharper_convert_if_statement_to_null_coalescing_assignment_highlighting = suggestion
+resharper_convert_if_statement_to_null_coalescing_expression_highlighting = suggestion
+resharper_convert_if_statement_to_return_statement_highlighting = hint
+resharper_convert_if_statement_to_switch_expression_highlighting = hint
+resharper_convert_if_statement_to_switch_statement_highlighting = hint
+resharper_convert_if_to_or_expression_highlighting = suggestion
+resharper_convert_nullable_to_short_form_highlighting = suggestion
+resharper_convert_switch_statement_to_switch_expression_highlighting = hint
+resharper_convert_to_auto_property_highlighting = suggestion
+resharper_convert_to_auto_property_when_possible_highlighting = hint
+resharper_convert_to_auto_property_with_private_setter_highlighting = hint
+resharper_convert_to_compound_assignment_highlighting = hint
+resharper_convert_to_constant_global_highlighting = hint
+resharper_convert_to_constant_local_highlighting = hint
+resharper_convert_to_lambda_expression_highlighting = suggestion
+resharper_convert_to_lambda_expression_when_possible_highlighting = none
+resharper_convert_to_local_function_highlighting = suggestion
+resharper_convert_to_null_coalescing_compound_assignment_highlighting = suggestion
+resharper_convert_to_primary_constructor_highlighting = suggestion
+resharper_convert_to_static_class_highlighting = suggestion
+resharper_convert_to_using_declaration_highlighting = suggestion
+resharper_convert_to_vb_auto_property_highlighting = suggestion
+resharper_convert_to_vb_auto_property_when_possible_highlighting = hint
+resharper_convert_to_vb_auto_property_with_private_setter_highlighting = hint
+resharper_convert_type_check_pattern_to_null_check_highlighting = warning
+resharper_convert_type_check_to_null_check_highlighting = warning
+resharper_co_variant_array_conversion_highlighting = warning
+resharper_c_declaration_with_implicit_int_type_highlighting = warning
+resharper_c_sharp_build_cs_invalid_module_name_highlighting = warning
+resharper_c_sharp_missing_plugin_dependency_highlighting = warning
+resharper_default_value_attribute_for_optional_parameter_highlighting = warning
+resharper_dl_tag_contains_non_dt_or_dd_elements_highlighting = hint
+resharper_double_negation_in_pattern_highlighting = suggestion
+resharper_double_negation_operator_highlighting = suggestion
+resharper_duplicate_resource_highlighting = warning
+resharper_dynamic_shift_right_op_is_not_int_highlighting = warning
+resharper_empty_constructor_highlighting = warning
+resharper_empty_destructor_highlighting = warning
+resharper_empty_embedded_statement_highlighting = warning
+resharper_empty_for_statement_highlighting = warning
+resharper_empty_general_catch_clause_highlighting = warning
+resharper_empty_namespace_highlighting = warning
+resharper_empty_statement_highlighting = warning
+resharper_empty_title_tag_highlighting = hint
+resharper_enforce_do_while_statement_braces_highlighting = error
+resharper_enforce_fixed_statement_braces_highlighting = error
+resharper_enforce_foreach_statement_braces_highlighting = error
+resharper_enforce_for_statement_braces_highlighting = error
+resharper_enforce_if_statement_braces_highlighting = error
+resharper_enforce_lock_statement_braces_highlighting = error
+resharper_enforce_using_statement_braces_highlighting = error
+resharper_enforce_while_statement_braces_highlighting = error
+resharper_entity_name_captured_only_global_highlighting = warning
+resharper_entity_name_captured_only_local_highlighting = warning
+resharper_enumerable_sum_in_explicit_unchecked_context_highlighting = warning
+resharper_enum_underlying_type_is_int_highlighting = warning
+resharper_equal_expression_comparison_highlighting = warning
+resharper_escaped_keyword_highlighting = warning
+resharper_event_never_invoked_global_highlighting = suggestion
+resharper_event_never_subscribed_to_global_highlighting = suggestion
+resharper_event_never_subscribed_to_local_highlighting = suggestion
+resharper_event_unsubscription_via_anonymous_delegate_highlighting = warning
+resharper_exception_passed_as_template_argument_problem_highlighting = warning
+resharper_explicit_caller_info_argument_highlighting = warning
+resharper_expression_is_always_null_highlighting = warning
+resharper_field_can_be_made_read_only_global_highlighting = suggestion
+resharper_field_can_be_made_read_only_local_highlighting = suggestion
+resharper_field_hides_interface_property_with_default_implementation_highlighting = warning
+resharper_foreach_can_be_converted_to_query_using_another_get_enumerator_highlighting = hint
+resharper_foreach_can_be_partly_converted_to_query_using_another_get_enumerator_highlighting = hint
+resharper_format_string_placeholders_mismatch_highlighting = warning
+resharper_format_string_problem_highlighting = warning
+resharper_for_can_be_converted_to_foreach_highlighting = suggestion
+resharper_for_statement_condition_is_true_highlighting = warning
+resharper_function_complexity_overflow_highlighting = none
+resharper_function_never_returns_highlighting = warning
+resharper_function_recursive_on_all_paths_highlighting = warning
+resharper_gc_suppress_finalize_for_type_without_destructor_highlighting = warning
+resharper_generic_enumerator_not_disposed_highlighting = warning
+resharper_heap_view_boxing_allocation_highlighting = hint
+resharper_heap_view_can_avoid_closure_highlighting = suggestion
+resharper_heap_view_closure_allocation_highlighting = hint
+resharper_heap_view_delegate_allocation_highlighting = hint
+resharper_heap_view_object_allocation_evident_highlighting = hint
+resharper_heap_view_object_allocation_highlighting = hint
+resharper_heap_view_object_allocation_possible_highlighting = hint
+resharper_heap_view_possible_boxing_allocation_highlighting = hint
+resharper_heuristic_unreachable_code_highlighting = warning
+resharper_identifier_typo_highlighting = suggestion
+resharper_ignored_directive_highlighting = warning
+resharper_inactive_preprocessor_branch_highlighting = warning
+resharper_inconsistently_synchronized_field_highlighting = warning
+resharper_inconsistent_context_log_property_naming_highlighting = warning
+resharper_inconsistent_log_property_naming_highlighting = warning
+resharper_inconsistent_naming_highlighting = warning
+resharper_inconsistent_order_of_locks_highlighting = warning
+resharper_incorrect_blank_lines_near_braces_highlighting = none
+resharper_indexing_by_invalid_range_highlighting = warning
+resharper_inheritdoc_consider_usage_highlighting = none
+resharper_inheritdoc_invalid_usage_highlighting = warning
+resharper_inline_out_variable_declaration_highlighting = suggestion
+resharper_inline_temporary_variable_highlighting = hint
+resharper_internal_or_private_member_not_documented_highlighting = none
+resharper_interpolated_string_expression_is_not_i_formattable_highlighting = warning
+resharper_introduce_optional_parameters_global_highlighting = suggestion
+resharper_introduce_optional_parameters_local_highlighting = suggestion
+resharper_int_division_by_zero_highlighting = warning
+resharper_int_variable_overflow_highlighting = warning
+resharper_int_variable_overflow_in_checked_context_highlighting = warning
+resharper_int_variable_overflow_in_unchecked_context_highlighting = warning
+resharper_invalid_value_type_highlighting = warning
+resharper_invalid_xml_doc_comment_highlighting = warning
+resharper_invert_condition_1_highlighting = hint
+resharper_invert_if_highlighting = hint
+resharper_invocation_is_skipped_highlighting = hint
+resharper_invoke_as_extension_method_highlighting = suggestion
+resharper_is_expression_always_false_highlighting = warning
+resharper_is_expression_always_true_highlighting = warning
+resharper_iterator_method_result_is_ignored_highlighting = warning
+resharper_iterator_never_returns_highlighting = warning
+resharper_join_declaration_and_initializer_highlighting = suggestion
+resharper_join_null_check_with_usage_highlighting = suggestion
+resharper_join_null_check_with_usage_when_possible_highlighting = none
+resharper_lambda_expression_can_be_made_static_highlighting = none
+resharper_lambda_expression_must_be_static_highlighting = suggestion
+resharper_lambda_should_not_capture_context_highlighting = warning
+resharper_localizable_element_highlighting = warning
+resharper_local_function_can_be_made_static_highlighting = none
+resharper_local_function_hides_method_highlighting = warning
+resharper_local_variable_hides_member_highlighting = warning
+resharper_log_message_is_sentence_problem_highlighting = warning
+resharper_long_literal_ending_lower_l_highlighting = warning
+resharper_loop_can_be_converted_to_query_highlighting = hint
+resharper_loop_can_be_partly_converted_to_query_highlighting = none
+resharper_loop_variable_is_never_changed_inside_loop_highlighting = warning
+resharper_markup_attribute_typo_highlighting = suggestion
+resharper_markup_text_typo_highlighting = suggestion
+resharper_math_abs_method_is_redundant_highlighting = warning
+resharper_math_clamp_min_greater_than_max_highlighting = warning
+resharper_meaningless_default_parameter_value_highlighting = warning
+resharper_member_can_be_internal_highlighting = none
+resharper_member_can_be_made_static_global_highlighting = hint
+resharper_member_can_be_made_static_local_highlighting = hint
+resharper_member_can_be_private_global_highlighting = suggestion
+resharper_member_can_be_private_local_highlighting = suggestion
+resharper_member_can_be_protected_global_highlighting = suggestion
+resharper_member_can_be_protected_local_highlighting = suggestion
+resharper_member_hides_interface_member_with_default_implementation_highlighting = warning
+resharper_member_hides_static_from_outer_class_highlighting = warning
+resharper_member_initializer_value_ignored_highlighting = warning
+resharper_merge_and_pattern_highlighting = suggestion
+resharper_merge_cast_with_type_check_highlighting = suggestion
+resharper_merge_conditional_expression_highlighting = suggestion
+resharper_merge_conditional_expression_when_possible_highlighting = none
+resharper_merge_into_logical_pattern_highlighting = hint
+resharper_merge_into_negated_pattern_highlighting = hint
+resharper_merge_into_pattern_highlighting = suggestion
+resharper_merge_nested_property_patterns_highlighting = suggestion
+resharper_merge_sequential_checks_highlighting = hint
+resharper_merge_sequential_checks_when_possible_highlighting = none
+resharper_method_has_async_overload_highlighting = suggestion
+resharper_method_has_async_overload_with_cancellation_highlighting = suggestion
+resharper_method_overload_with_optional_parameter_highlighting = warning
+resharper_method_supports_cancellation_highlighting = suggestion
+resharper_missing_alt_attribute_in_img_tag_highlighting = hint
+resharper_missing_blank_lines_highlighting = none
+resharper_missing_body_tag_highlighting = warning
+resharper_missing_head_and_body_tags_highlighting = warning
+resharper_missing_head_tag_highlighting = warning
+resharper_missing_indent_highlighting = none
+resharper_missing_linebreak_highlighting = none
+resharper_missing_space_highlighting = none
+resharper_missing_title_tag_highlighting = hint
+resharper_more_specific_foreach_variable_type_available_highlighting = suggestion
+resharper_move_to_existing_positional_deconstruction_pattern_highlighting = hint
+resharper_multiple_nullable_attributes_usage_highlighting = warning
+resharper_multiple_order_by_highlighting = warning
+resharper_multiple_resolve_candidates_in_text_highlighting = warning
+resharper_multiple_spaces_highlighting = none
+resharper_multiple_statements_on_one_line_highlighting = none
+resharper_multiple_type_members_on_one_line_highlighting = none
+resharper_must_use_return_value_highlighting = warning
+resharper_negation_of_relational_pattern_highlighting = suggestion
+resharper_negative_equality_expression_highlighting = suggestion
+resharper_negative_index_highlighting = warning
+resharper_nested_string_interpolation_highlighting = suggestion
+resharper_non_atomic_compound_operator_highlighting = warning
+resharper_non_constant_equality_expression_has_constant_result_highlighting = warning
+resharper_non_parsable_element_highlighting = warning
+resharper_non_readonly_member_in_get_hash_code_highlighting = warning
+resharper_non_volatile_field_in_double_check_locking_highlighting = warning
+resharper_not_accessed_field_global_highlighting = suggestion
+resharper_not_accessed_field_local_highlighting = warning
+resharper_not_accessed_positional_property_global_highlighting = warning
+resharper_not_accessed_positional_property_local_highlighting = warning
+resharper_not_accessed_variable_highlighting = warning
+resharper_not_assigned_out_parameter_highlighting = warning
+resharper_not_declared_in_parent_culture_highlighting = warning
+resharper_not_null_member_is_not_initialized_highlighting = warning
+resharper_not_observable_annotation_redundancy_highlighting = warning
+resharper_not_overridden_in_specific_culture_highlighting = warning
+resharper_not_resolved_in_text_highlighting = warning
+resharper_object_creation_as_statement_highlighting = warning
+resharper_obsolete_element_error_highlighting = error
+resharper_obsolete_element_highlighting = warning
+resharper_ol_tag_contains_non_li_elements_highlighting = hint
+resharper_one_way_operation_contract_with_return_type_highlighting = warning
+resharper_operation_contract_without_service_contract_highlighting = warning
+resharper_operator_is_can_be_used_highlighting = warning
+resharper_optional_parameter_hierarchy_mismatch_highlighting = warning
+resharper_optional_parameter_ref_out_highlighting = warning
+resharper_other_tags_inside_script1_highlighting = error
+resharper_other_tags_inside_script2_highlighting = error
+resharper_other_tags_inside_unclosed_script_highlighting = error
+resharper_outdent_is_off_prev_level_highlighting = none
+resharper_out_parameter_value_is_always_discarded_global_highlighting = suggestion
+resharper_out_parameter_value_is_always_discarded_local_highlighting = warning
+resharper_overridden_with_empty_value_highlighting = warning
+resharper_overridden_with_same_value_highlighting = suggestion
+resharper_parameter_hides_member_highlighting = warning
+resharper_parameter_only_used_for_precondition_check_global_highlighting = suggestion
+resharper_parameter_only_used_for_precondition_check_local_highlighting = warning
+resharper_parameter_type_can_be_enumerable_global_highlighting = hint
+resharper_parameter_type_can_be_enumerable_local_highlighting = hint
+resharper_partial_method_parameter_name_mismatch_highlighting = warning
+resharper_partial_method_with_single_part_highlighting = warning
+resharper_partial_type_with_single_part_highlighting = warning
+resharper_pass_string_interpolation_highlighting = hint
+resharper_pattern_always_matches_highlighting = warning
+resharper_pattern_is_always_true_or_false_highlighting = warning
+resharper_pattern_never_matches_highlighting = warning
+resharper_polymorphic_field_like_event_invocation_highlighting = warning
+resharper_positional_property_used_problem_highlighting = warning
+resharper_possible_infinite_inheritance_highlighting = warning
+resharper_possible_intended_rethrow_highlighting = warning
+resharper_possible_interface_member_ambiguity_highlighting = warning
+resharper_possible_invalid_cast_exception_highlighting = warning
+resharper_possible_invalid_cast_exception_in_foreach_loop_highlighting = warning
+resharper_possible_invalid_operation_exception_highlighting = warning
+resharper_possible_loss_of_fraction_highlighting = warning
+resharper_possible_mistaken_argument_highlighting = warning
+resharper_possible_mistaken_call_to_get_type_1_highlighting = warning
+resharper_possible_mistaken_call_to_get_type_2_highlighting = warning
+resharper_possible_multiple_enumeration_highlighting = warning
+resharper_possible_multiple_write_access_in_double_check_locking_highlighting = warning
+resharper_possible_null_reference_exception_highlighting = warning
+resharper_possible_struct_member_modification_of_non_variable_struct_highlighting = warning
+resharper_possible_unintended_linear_search_in_set_highlighting = warning
+resharper_possible_unintended_queryable_as_enumerable_highlighting = suggestion
+resharper_possible_unintended_reference_comparison_highlighting = warning
+resharper_possible_write_to_me_highlighting = warning
+resharper_possibly_impure_method_call_on_readonly_variable_highlighting = warning
+resharper_possibly_missing_indexer_initializer_comma_highlighting = warning
+resharper_possibly_mistaken_use_of_interpolated_string_insert_highlighting = warning
+resharper_possibly_mistaken_use_of_params_method_highlighting = warning
+resharper_private_field_can_be_converted_to_local_variable_highlighting = warning
+resharper_property_can_be_made_init_only_global_highlighting = suggestion
+resharper_property_can_be_made_init_only_local_highlighting = suggestion
+resharper_property_not_resolved_highlighting = error
+resharper_public_constructor_in_abstract_class_highlighting = suggestion
+resharper_pure_attribute_on_void_method_highlighting = warning
+resharper_read_access_in_double_check_locking_highlighting = warning
+resharper_redundant_abstract_modifier_highlighting = warning
+resharper_redundant_always_match_subpattern_highlighting = suggestion
+resharper_redundant_anonymous_type_property_name_highlighting = warning
+resharper_redundant_argument_default_value_highlighting = warning
+resharper_redundant_array_creation_expression_highlighting = hint
+resharper_redundant_array_lower_bound_specification_highlighting = warning
+resharper_redundant_assignment_highlighting = warning
+resharper_redundant_attribute_parentheses_highlighting = hint
+resharper_redundant_attribute_usage_property_highlighting = suggestion
+resharper_redundant_base_constructor_call_highlighting = warning
+resharper_redundant_base_qualifier_highlighting = warning
+resharper_redundant_blank_lines_highlighting = none
+resharper_redundant_bool_compare_highlighting = warning
+resharper_redundant_case_label_highlighting = warning
+resharper_redundant_cast_highlighting = warning
+resharper_redundant_catch_clause_highlighting = warning
+resharper_redundant_check_before_assignment_highlighting = warning
+resharper_redundant_collection_initializer_element_braces_highlighting = hint
+resharper_redundant_configure_await_highlighting = suggestion
+resharper_redundant_declaration_semicolon_highlighting = hint
+resharper_redundant_default_member_initializer_highlighting = warning
+resharper_redundant_delegate_creation_highlighting = warning
+resharper_redundant_disable_warning_comment_highlighting = warning
+resharper_redundant_discard_designation_highlighting = suggestion
+resharper_redundant_empty_case_else_highlighting = warning
+resharper_redundant_empty_finally_block_highlighting = warning
+resharper_redundant_empty_object_creation_argument_list_highlighting = hint
+resharper_redundant_empty_object_or_collection_initializer_highlighting = warning
+resharper_redundant_empty_switch_section_highlighting = warning
+resharper_redundant_enumerable_cast_call_highlighting = warning
+resharper_redundant_enum_case_label_for_default_section_highlighting = none
+resharper_redundant_explicit_array_creation_highlighting = warning
+resharper_redundant_explicit_array_size_highlighting = warning
+resharper_redundant_explicit_nullable_creation_highlighting = warning
+resharper_redundant_explicit_params_array_creation_highlighting = suggestion
+resharper_redundant_explicit_positional_property_declaration_highlighting = warning
+resharper_redundant_explicit_tuple_component_name_highlighting = warning
+resharper_redundant_extends_list_entry_highlighting = warning
+resharper_redundant_fixed_pointer_declaration_highlighting = suggestion
+resharper_redundant_if_else_block_highlighting = hint
+resharper_redundant_if_statement_then_keyword_highlighting = none
+resharper_redundant_immediate_delegate_invocation_highlighting = suggestion
+resharper_redundant_include_highlighting = warning
+resharper_redundant_is_before_relational_pattern_highlighting = suggestion
+resharper_redundant_iterator_keyword_highlighting = warning
+resharper_redundant_jump_statement_highlighting = warning
+resharper_redundant_lambda_parameter_type_highlighting = warning
+resharper_redundant_lambda_signature_parentheses_highlighting = hint
+resharper_redundant_linebreak_highlighting = none
+resharper_redundant_logical_conditional_expression_operand_highlighting = warning
+resharper_redundant_me_qualifier_highlighting = warning
+resharper_redundant_my_base_qualifier_highlighting = warning
+resharper_redundant_my_class_qualifier_highlighting = warning
+resharper_redundant_name_qualifier_highlighting = warning
+resharper_redundant_not_null_constraint_highlighting = warning
+resharper_redundant_nullable_annotation_on_reference_type_constraint_highlighting = warning
+resharper_redundant_nullable_annotation_on_type_constraint_has_non_nullable_base_type_highlighting = warning
+resharper_redundant_nullable_annotation_on_type_constraint_has_non_nullable_type_kind_highlighting = warning
+resharper_redundant_nullable_flow_attribute_highlighting = warning
+resharper_redundant_nullable_type_mark_highlighting = warning
+resharper_redundant_nullness_attribute_with_nullable_reference_types_highlighting = warning
+resharper_redundant_overflow_checking_context_highlighting = warning
+resharper_redundant_overload_global_highlighting = suggestion
+resharper_redundant_overload_local_highlighting = suggestion
+resharper_redundant_overridden_member_highlighting = warning
+resharper_redundant_params_highlighting = warning
+resharper_redundant_parentheses_highlighting = none
+resharper_redundant_pattern_parentheses_highlighting = hint
+resharper_redundant_property_parentheses_highlighting = hint
+resharper_redundant_property_pattern_clause_highlighting = suggestion
+resharper_redundant_qualifier_highlighting = warning
+resharper_redundant_query_order_by_ascending_keyword_highlighting = hint
+resharper_redundant_range_bound_highlighting = suggestion
+resharper_redundant_readonly_modifier_highlighting = suggestion
+resharper_redundant_record_body_highlighting = warning
+resharper_redundant_record_class_keyword_highlighting = warning
+resharper_redundant_setter_value_parameter_declaration_highlighting = hint
+resharper_redundant_space_highlighting = none
+resharper_redundant_string_format_call_highlighting = warning
+resharper_redundant_string_interpolation_highlighting = suggestion
+resharper_redundant_string_to_char_array_call_highlighting = warning
+resharper_redundant_string_type_highlighting = suggestion
+resharper_redundant_suppress_nullable_warning_expression_highlighting = warning
+resharper_redundant_ternary_expression_highlighting = warning
+resharper_redundant_to_string_call_for_value_type_highlighting = hint
+resharper_redundant_to_string_call_highlighting = warning
+resharper_redundant_type_arguments_of_method_highlighting = warning
+resharper_redundant_type_check_in_pattern_highlighting = warning
+resharper_redundant_unsafe_context_highlighting = warning
+resharper_redundant_using_directive_global_highlighting = warning
+resharper_redundant_using_directive_highlighting = warning
+resharper_redundant_verbatim_prefix_highlighting = suggestion
+resharper_redundant_verbatim_string_prefix_highlighting = suggestion
+resharper_redundant_with_expression_highlighting = suggestion
+resharper_reference_equals_with_value_type_highlighting = warning
+resharper_reg_exp_inspections_highlighting = warning
+resharper_remove_constructor_invocation_highlighting = none
+resharper_remove_redundant_braces_highlighting = warning
+resharper_remove_redundant_or_statement_false_highlighting = suggestion
+resharper_remove_redundant_or_statement_true_highlighting = suggestion
+resharper_remove_to_list_1_highlighting = suggestion
+resharper_remove_to_list_2_highlighting = suggestion
+resharper_replace_auto_property_with_computed_property_highlighting = hint
+resharper_replace_object_pattern_with_var_pattern_highlighting = suggestion
+resharper_replace_slice_with_range_indexer_highlighting = hint
+resharper_replace_substring_with_range_indexer_highlighting = hint
+resharper_replace_with_first_or_default_1_highlighting = suggestion
+resharper_replace_with_first_or_default_2_highlighting = suggestion
+resharper_replace_with_first_or_default_3_highlighting = suggestion
+resharper_replace_with_first_or_default_4_highlighting = suggestion
+resharper_replace_with_last_or_default_1_highlighting = suggestion
+resharper_replace_with_last_or_default_2_highlighting = suggestion
+resharper_replace_with_last_or_default_3_highlighting = suggestion
+resharper_replace_with_last_or_default_4_highlighting = suggestion
+resharper_replace_with_of_type_1_highlighting = suggestion
+resharper_replace_with_of_type_2_highlighting = suggestion
+resharper_replace_with_of_type_3_highlighting = suggestion
+resharper_replace_with_of_type_any_1_highlighting = suggestion
+resharper_replace_with_of_type_any_2_highlighting = suggestion
+resharper_replace_with_of_type_count_1_highlighting = suggestion
+resharper_replace_with_of_type_count_2_highlighting = suggestion
+resharper_replace_with_of_type_first_1_highlighting = suggestion
+resharper_replace_with_of_type_first_2_highlighting = suggestion
+resharper_replace_with_of_type_first_or_default_1_highlighting = suggestion
+resharper_replace_with_of_type_first_or_default_2_highlighting = suggestion
+resharper_replace_with_of_type_last_1_highlighting = suggestion
+resharper_replace_with_of_type_last_2_highlighting = suggestion
+resharper_replace_with_of_type_last_or_default_1_highlighting = suggestion
+resharper_replace_with_of_type_last_or_default_2_highlighting = suggestion
+resharper_replace_with_of_type_long_count_highlighting = suggestion
+resharper_replace_with_of_type_single_1_highlighting = suggestion
+resharper_replace_with_of_type_single_2_highlighting = suggestion
+resharper_replace_with_of_type_single_or_default_1_highlighting = suggestion
+resharper_replace_with_of_type_single_or_default_2_highlighting = suggestion
+resharper_replace_with_of_type_where_highlighting = suggestion
+resharper_replace_with_simple_assignment_false_highlighting = suggestion
+resharper_replace_with_simple_assignment_true_highlighting = suggestion
+resharper_replace_with_single_assignment_false_highlighting = suggestion
+resharper_replace_with_single_assignment_true_highlighting = suggestion
+resharper_replace_with_single_call_to_any_highlighting = suggestion
+resharper_replace_with_single_call_to_count_highlighting = suggestion
+resharper_replace_with_single_call_to_first_highlighting = suggestion
+resharper_replace_with_single_call_to_first_or_default_highlighting = suggestion
+resharper_replace_with_single_call_to_last_highlighting = suggestion
+resharper_replace_with_single_call_to_last_or_default_highlighting = suggestion
+resharper_replace_with_single_call_to_single_highlighting = suggestion
+resharper_replace_with_single_call_to_single_or_default_highlighting = suggestion
+resharper_replace_with_single_or_default_1_highlighting = suggestion
+resharper_replace_with_single_or_default_2_highlighting = suggestion
+resharper_replace_with_single_or_default_3_highlighting = suggestion
+resharper_replace_with_single_or_default_4_highlighting = suggestion
+resharper_replace_with_string_is_null_or_empty_highlighting = suggestion
+resharper_required_base_types_conflict_highlighting = warning
+resharper_required_base_types_direct_conflict_highlighting = warning
+resharper_required_base_types_is_not_inherited_highlighting = warning
+resharper_resource_item_not_resolved_highlighting = error
+resharper_resource_not_resolved_highlighting = error
+resharper_resx_not_resolved_highlighting = warning
+resharper_return_type_can_be_enumerable_global_highlighting = hint
+resharper_return_type_can_be_enumerable_local_highlighting = hint
+resharper_return_type_can_be_not_nullable_highlighting = warning
+resharper_return_value_of_pure_method_is_not_used_highlighting = warning
+resharper_route_templates_action_route_prefix_can_be_extracted_to_controller_route_highlighting = hint
+resharper_route_templates_ambiguous_matching_constraint_constructor_highlighting = warning
+resharper_route_templates_ambiguous_route_match_highlighting = warning
+resharper_route_templates_constraint_argument_cannot_be_converted_highlighting = warning
+resharper_route_templates_controller_route_parameter_is_not_passed_to_methods_highlighting = hint
+resharper_route_templates_duplicated_parameter_highlighting = warning
+resharper_route_templates_matching_constraint_constructor_not_resolved_highlighting = warning
+resharper_route_templates_method_missing_route_parameters_highlighting = hint
+resharper_route_templates_optional_parameter_can_be_preceded_only_by_single_period_highlighting = warning
+resharper_route_templates_optional_parameter_must_be_at_the_end_of_segment_highlighting = warning
+resharper_route_templates_parameter_constraint_can_be_specified_highlighting = hint
+resharper_route_templates_parameter_type_and_constraints_mismatch_highlighting = warning
+resharper_route_templates_parameter_type_can_be_made_stricter_highlighting = suggestion
+resharper_route_templates_route_parameter_constraint_not_resolved_highlighting = warning
+resharper_route_templates_route_parameter_is_not_passed_to_method_highlighting = hint
+resharper_route_templates_route_token_not_resolved_highlighting = warning
+resharper_route_templates_symbol_not_resolved_highlighting = warning
+resharper_route_templates_syntax_error_highlighting = warning
+resharper_safe_cast_is_used_as_type_check_highlighting = warning
+resharper_script_tag_has_both_src_and_content_attributes_highlighting = error
+resharper_script_tag_with_content_before_includes_highlighting = hint
+resharper_sealed_member_in_sealed_class_highlighting = warning
+resharper_separate_control_transfer_statement_highlighting = none
+resharper_service_contract_without_operations_highlighting = warning
+resharper_shift_expression_real_shift_count_is_zero_highlighting = warning
+resharper_shift_expression_result_equals_zero_highlighting = warning
+resharper_shift_expression_right_operand_not_equal_real_count_highlighting = warning
+resharper_shift_expression_zero_left_operand_highlighting = warning
+resharper_similar_anonymous_type_nearby_highlighting = hint
+resharper_simplify_conditional_operator_highlighting = suggestion
+resharper_simplify_conditional_ternary_expression_highlighting = suggestion
+resharper_simplify_i_if_highlighting = suggestion
+resharper_simplify_linq_expression_use_all_highlighting = suggestion
+resharper_simplify_linq_expression_use_any_highlighting = suggestion
+resharper_simplify_string_interpolation_highlighting = suggestion
+resharper_specify_a_culture_in_string_conversion_explicitly_highlighting = warning
+resharper_specify_string_comparison_highlighting = hint
+resharper_spin_lock_in_readonly_field_highlighting = warning
+resharper_stack_alloc_inside_loop_highlighting = warning
+resharper_static_member_initializer_referes_to_member_below_highlighting = warning
+resharper_static_member_in_generic_type_highlighting = warning
+resharper_static_problem_in_text_highlighting = warning
+resharper_string_compare_is_culture_specific_1_highlighting = warning
+resharper_string_compare_is_culture_specific_2_highlighting = warning
+resharper_string_compare_is_culture_specific_3_highlighting = warning
+resharper_string_compare_is_culture_specific_4_highlighting = warning
+resharper_string_compare_is_culture_specific_5_highlighting = warning
+resharper_string_compare_is_culture_specific_6_highlighting = warning
+resharper_string_compare_to_is_culture_specific_highlighting = warning
+resharper_string_ends_with_is_culture_specific_highlighting = none
+resharper_string_index_of_is_culture_specific_1_highlighting = warning
+resharper_string_index_of_is_culture_specific_2_highlighting = warning
+resharper_string_index_of_is_culture_specific_3_highlighting = warning
+resharper_string_last_index_of_is_culture_specific_1_highlighting = warning
+resharper_string_last_index_of_is_culture_specific_2_highlighting = warning
+resharper_string_last_index_of_is_culture_specific_3_highlighting = warning
+resharper_string_literal_as_interpolation_argument_highlighting = suggestion
+resharper_string_literal_typo_highlighting = suggestion
+resharper_string_starts_with_is_culture_specific_highlighting = none
+resharper_structured_message_template_problem_highlighting = warning
+resharper_struct_can_be_made_read_only_highlighting = suggestion
+resharper_struct_member_can_be_made_read_only_highlighting = none
+resharper_suggest_base_type_for_parameter_highlighting = hint
+resharper_suggest_base_type_for_parameter_in_constructor_highlighting = hint
+resharper_suggest_discard_declaration_var_style_highlighting = hint
+resharper_suggest_var_or_type_deconstruction_declarations_highlighting = hint
+resharper_suppress_nullable_warning_expression_as_inverted_is_expression_highlighting = warning
+resharper_suspicious_lock_over_synchronization_primitive_highlighting = warning
+resharper_suspicious_math_sign_method_highlighting = warning
+resharper_suspicious_parameter_name_in_argument_null_exception_highlighting = warning
+resharper_suspicious_type_conversion_global_highlighting = warning
+resharper_swap_via_deconstruction_highlighting = suggestion
+resharper_switch_expression_handles_some_known_enum_values_with_exception_in_default_highlighting = hint
+resharper_switch_statement_for_enum_misses_default_section_highlighting = hint
+resharper_switch_statement_handles_some_known_enum_values_with_default_highlighting = hint
+resharper_switch_statement_missing_some_enum_cases_no_default_highlighting = hint
+resharper_symbol_from_not_copied_locally_reference_used_warning_highlighting = warning
+resharper_tabs_and_spaces_mismatch_highlighting = none
+resharper_tabs_are_disallowed_highlighting = none
+resharper_tabs_outside_indent_highlighting = none
+resharper_tail_recursive_call_highlighting = hint
+resharper_template_duplicate_property_problem_highlighting = warning
+resharper_template_format_string_problem_highlighting = warning
+resharper_template_is_not_compile_time_constant_problem_highlighting = warning
+resharper_thread_static_at_instance_field_highlighting = warning
+resharper_thread_static_field_has_initializer_highlighting = warning
+resharper_too_wide_local_variable_scope_highlighting = suggestion
+resharper_try_cast_always_succeeds_highlighting = suggestion
+resharper_try_statements_can_be_merged_highlighting = hint
+resharper_type_parameter_can_be_variant_highlighting = suggestion
+resharper_ul_tag_contains_non_li_elements_highlighting = hint
+resharper_unassigned_field_global_highlighting = suggestion
+resharper_unassigned_field_local_highlighting = warning
+resharper_unassigned_get_only_auto_property_highlighting = warning
+resharper_unassigned_readonly_field_highlighting = warning
+resharper_unclosed_script_highlighting = error
+resharper_unexpected_attribute_highlighting = warning
+resharper_unexpected_directive_highlighting = warning
+resharper_unnecessary_whitespace_highlighting = none
+resharper_unreachable_switch_arm_due_to_integer_analysis_highlighting = warning
+resharper_unreachable_switch_case_due_to_integer_analysis_highlighting = warning
+resharper_unreal_header_tool_error_highlighting = error
+resharper_unreal_header_tool_parser_error_highlighting = error
+resharper_unreal_header_tool_warning_highlighting = warning
+resharper_unsupported_required_base_type_highlighting = warning
+resharper_unused_anonymous_method_signature_highlighting = warning
+resharper_unused_auto_property_accessor_global_highlighting = warning
+resharper_unused_auto_property_accessor_local_highlighting = warning
+resharper_unused_import_clause_highlighting = warning
+resharper_unused_local_function_highlighting = warning
+resharper_unused_local_function_parameter_highlighting = warning
+resharper_unused_local_function_return_value_highlighting = warning
+resharper_unused_member_global_highlighting = suggestion
+resharper_unused_member_hierarchy_global_highlighting = suggestion
+resharper_unused_member_hierarchy_local_highlighting = warning
+resharper_unused_member_in_super_global_highlighting = suggestion
+resharper_unused_member_in_super_local_highlighting = warning
+resharper_unused_member_local_highlighting = warning
+resharper_unused_method_return_value_global_highlighting = suggestion
+resharper_unused_method_return_value_local_highlighting = warning
+resharper_unused_parameter_global_highlighting = suggestion
+resharper_unused_parameter_in_partial_method_highlighting = warning
+resharper_unused_parameter_local_highlighting = warning
+resharper_unused_tuple_component_in_return_value_highlighting = warning
+resharper_unused_type_global_highlighting = suggestion
+resharper_unused_type_local_highlighting = warning
+resharper_unused_type_parameter_highlighting = warning
+resharper_unused_variable_highlighting = warning
+resharper_useless_binary_operation_highlighting = warning
+resharper_useless_comparison_to_integral_constant_highlighting = warning
+resharper_use_array_creation_expression_1_highlighting = suggestion
+resharper_use_array_creation_expression_2_highlighting = suggestion
+resharper_use_array_empty_method_highlighting = suggestion
+resharper_use_await_using_highlighting = suggestion
+resharper_use_cancellation_token_for_i_async_enumerable_highlighting = suggestion
+resharper_use_collection_count_property_highlighting = suggestion
+resharper_use_configure_await_false_for_async_disposable_highlighting = none
+resharper_use_configure_await_false_highlighting = suggestion
+resharper_use_deconstruction_highlighting = hint
+resharper_use_deconstruction_on_parameter_highlighting = hint
+resharper_use_empty_types_field_highlighting = suggestion
+resharper_use_event_args_empty_field_highlighting = suggestion
+resharper_use_format_specifier_in_format_string_highlighting = suggestion
+resharper_use_implicitly_typed_variable_evident_highlighting = hint
+resharper_use_implicitly_typed_variable_highlighting = none
+resharper_use_implicit_by_val_modifier_highlighting = hint
+resharper_use_indexed_property_highlighting = suggestion
+resharper_use_index_from_end_expression_highlighting = suggestion
+resharper_use_is_operator_1_highlighting = suggestion
+resharper_use_is_operator_2_highlighting = suggestion
+resharper_use_method_any_0_highlighting = suggestion
+resharper_use_method_any_1_highlighting = suggestion
+resharper_use_method_any_2_highlighting = suggestion
+resharper_use_method_any_3_highlighting = suggestion
+resharper_use_method_any_4_highlighting = suggestion
+resharper_use_method_is_instance_of_type_highlighting = suggestion
+resharper_use_nameof_expression_for_part_of_the_string_highlighting = none
+resharper_use_nameof_expression_highlighting = suggestion
+resharper_use_name_of_instead_of_type_of_highlighting = suggestion
+resharper_use_negated_pattern_in_is_expression_highlighting = hint
+resharper_use_negated_pattern_matching_highlighting = hint
+resharper_use_nullable_annotation_instead_of_attribute_highlighting = suggestion
+resharper_use_nullable_attributes_supported_by_compiler_highlighting = suggestion
+resharper_use_nullable_reference_types_annotation_syntax_highlighting = warning
+resharper_use_null_propagation_highlighting = hint
+resharper_use_null_propagation_when_possible_highlighting = none
+resharper_use_object_or_collection_initializer_highlighting = suggestion
+resharper_use_pattern_matching_highlighting = suggestion
+resharper_use_positional_deconstruction_pattern_highlighting = none
+resharper_use_string_interpolation_highlighting = suggestion
+resharper_use_switch_case_pattern_variable_highlighting = suggestion
+resharper_use_throw_if_null_method_highlighting = none
+resharper_use_verbatim_string_highlighting = hint
+resharper_value_parameter_not_used_highlighting = warning
+resharper_value_range_attribute_violation_highlighting = warning
+resharper_variable_can_be_not_nullable_highlighting = warning
+resharper_variable_hides_outer_variable_highlighting = warning
+resharper_virtual_member_call_in_constructor_highlighting = warning
+resharper_virtual_member_never_overridden_global_highlighting = suggestion
+resharper_virtual_member_never_overridden_local_highlighting = suggestion
+resharper_void_method_with_must_use_return_value_attribute_highlighting = warning
+resharper_with_expression_instead_of_initializer_highlighting = suggestion
+resharper_wrong_indent_size_highlighting = none
+resharper_xunit_xunit_test_with_console_output_highlighting = warning
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index ca1c7a3..03e89d4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,3 @@
-# ---> VisualStudio
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
@@ -35,6 +34,8 @@ bld/
# Visual Studio 2015/2017 cache/options directory
.vs/
+# Visual Studio Code
+.vscode/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
@@ -58,11 +59,14 @@ dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
-# .NET Core
+# .NET
project.lock.json
project.fragment.lock.json
artifacts/
+# Tye
+.tye/
+
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
@@ -398,3 +402,78 @@ FodyWeavers.xsd
# JetBrains Rider
*.sln.iml
+##
+## Visual studio for Mac
+##
+
+
+# globs
+Makefile.in
+*.userprefs
+*.usertasks
+config.make
+config.status
+aclocal.m4
+install-sh
+autom4te.cache/
+*.tar.gz
+tarballs/
+test-results/
+
+# Mac bundle stuff
+*.dmg
+*.app
+
+# content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+# content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore
+# Windows thumbnail cache files
+Thumbs.db
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.cab
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
\ No newline at end of file
diff --git a/EllieHub.sln b/EllieHub.sln
new file mode 100644
index 0000000..0dd6fb8
--- /dev/null
+++ b/EllieHub.sln
@@ -0,0 +1,25 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.6.33829.357
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "EllieHub", "EllieHub\EllieHub.csproj", "{6E399AD5-2130-4F97-A08F-397EFCE5872A}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {6E399AD5-2130-4F97-A08F-397EFCE5872A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {6E399AD5-2130-4F97-A08F-397EFCE5872A}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {6E399AD5-2130-4F97-A08F-397EFCE5872A}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {6E399AD5-2130-4F97-A08F-397EFCE5872A}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+ GlobalSection(ExtensibilityGlobals) = postSolution
+ SolutionGuid = {CB52F80B-4BF5-4BF2-AC3A-939FF8588802}
+ EndGlobalSection
+EndGlobal
diff --git a/EllieHub/App.axaml b/EllieHub/App.axaml
new file mode 100644
index 0000000..a729d4c
--- /dev/null
+++ b/EllieHub/App.axaml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/EllieHub/App.axaml.cs b/EllieHub/App.axaml.cs
new file mode 100644
index 0000000..64f278f
--- /dev/null
+++ b/EllieHub/App.axaml.cs
@@ -0,0 +1,80 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.ApplicationLifetimes;
+using Avalonia.Markup.Xaml;
+using Microsoft.Extensions.DependencyInjection;
+using EllieHub.Views.Windows;
+using System.Reflection;
+
+namespace EllieHub;
+
+///
+/// Defines the Avalonia application.
+///
+public partial class App : Application
+{
+ private DateTimeOffset _trayClickTime = DateTimeOffset.UnixEpoch;
+
+ ///
+ /// IoC container with all services required by the application.
+ ///
+ public IServiceProvider Services { get; } = new ServiceCollection()
+ .RegisterViewsAndViewModels(Assembly.GetExecutingAssembly())
+ .RegisterServices()
+ .BuildServiceProvider(true);
+
+ ///
+ public override void Initialize()
+ => AvaloniaXamlLoader.Load(this);
+
+ ///
+ public override void OnFrameworkInitializationCompleted()
+ {
+ if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
+ desktop.MainWindow = Services.GetRequiredService();
+
+ base.OnFrameworkInitializationCompleted();
+ }
+
+ ///
+ /// Executed when the "Open" menu in the tray icon is clicked.
+ ///
+ /// A .
+ /// A .
+ private void ShowApp(object sender, EventArgs eventArgs)
+ => Services.GetRequiredService().Show();
+
+ ///
+ /// Executed when the "Close" menu in the tray icon is clicked.
+ ///
+ /// A .
+ /// A .
+ private void CloseApp(object sender, EventArgs eventArgs)
+ => Services.GetRequiredService().Close();
+
+ ///
+ /// Executed when the tray icon is clicked.
+ ///
+ /// A .
+ /// A .
+ /// Shows or hides the application when the tray icon is double-clicked.
+ private void TrayDoubleClick(object sender, EventArgs eventArgs)
+ {
+ // If this is the first click or if the second click took longer than 0.3 seconds, exit.
+ if (_trayClickTime == DateTimeOffset.UnixEpoch || DateTimeOffset.Now.Subtract(_trayClickTime) > TimeSpan.FromSeconds(0.3))
+ {
+ _trayClickTime = DateTimeOffset.Now;
+ return;
+ }
+
+ // User has double-clicked the tray icon. Reset the timer.
+ _trayClickTime = DateTimeOffset.UnixEpoch;
+
+ var mainWindow = Services.GetRequiredService();
+
+ if (mainWindow.IsVisible)
+ mainWindow.Hide();
+ else
+ mainWindow.Show();
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/Assets/Dark/check_for_updates.png b/EllieHub/Assets/Dark/check_for_updates.png
new file mode 100644
index 0000000..3b6068e
Binary files /dev/null and b/EllieHub/Assets/Dark/check_for_updates.png differ
diff --git a/EllieHub/Assets/Dark/config.png b/EllieHub/Assets/Dark/config.png
new file mode 100644
index 0000000..6c3f53c
Binary files /dev/null and b/EllieHub/Assets/Dark/config.png differ
diff --git a/EllieHub/Assets/Dark/deps.png b/EllieHub/Assets/Dark/deps.png
new file mode 100644
index 0000000..a1ac4b2
Binary files /dev/null and b/EllieHub/Assets/Dark/deps.png differ
diff --git a/EllieHub/Assets/Dark/docs.png b/EllieHub/Assets/Dark/docs.png
new file mode 100644
index 0000000..1095ab6
Binary files /dev/null and b/EllieHub/Assets/Dark/docs.png differ
diff --git a/EllieHub/Assets/Dark/ellieupdatericon.ico b/EllieHub/Assets/Dark/ellieupdatericon.ico
new file mode 100644
index 0000000..f9e2f35
Binary files /dev/null and b/EllieHub/Assets/Dark/ellieupdatericon.ico differ
diff --git a/EllieHub/Assets/Dark/home.png b/EllieHub/Assets/Dark/home.png
new file mode 100644
index 0000000..a5a1d2f
Binary files /dev/null and b/EllieHub/Assets/Dark/home.png differ
diff --git a/EllieHub/Assets/Dark/icon-commands.png b/EllieHub/Assets/Dark/icon-commands.png
new file mode 100644
index 0000000..cb730aa
Binary files /dev/null and b/EllieHub/Assets/Dark/icon-commands.png differ
diff --git a/EllieHub/Assets/Dark/icon-embeds.png b/EllieHub/Assets/Dark/icon-embeds.png
new file mode 100644
index 0000000..f7a7fd3
Binary files /dev/null and b/EllieHub/Assets/Dark/icon-embeds.png differ
diff --git a/EllieHub/Assets/Dark/icon-link.png b/EllieHub/Assets/Dark/icon-link.png
new file mode 100644
index 0000000..3378fe0
Binary files /dev/null and b/EllieHub/Assets/Dark/icon-link.png differ
diff --git a/EllieHub/Assets/Dark/icon-suggest.png b/EllieHub/Assets/Dark/icon-suggest.png
new file mode 100644
index 0000000..88e02d8
Binary files /dev/null and b/EllieHub/Assets/Dark/icon-suggest.png differ
diff --git a/EllieHub/Assets/Dark/icon-support.png b/EllieHub/Assets/Dark/icon-support.png
new file mode 100644
index 0000000..15e0803
Binary files /dev/null and b/EllieHub/Assets/Dark/icon-support.png differ
diff --git a/EllieHub/Assets/Dark/terminal.png b/EllieHub/Assets/Dark/terminal.png
new file mode 100644
index 0000000..466abfb
Binary files /dev/null and b/EllieHub/Assets/Dark/terminal.png differ
diff --git a/EllieHub/Assets/Light/check_for_updates.png b/EllieHub/Assets/Light/check_for_updates.png
new file mode 100644
index 0000000..33ce32c
Binary files /dev/null and b/EllieHub/Assets/Light/check_for_updates.png differ
diff --git a/EllieHub/Assets/Light/config.png b/EllieHub/Assets/Light/config.png
new file mode 100644
index 0000000..2824920
Binary files /dev/null and b/EllieHub/Assets/Light/config.png differ
diff --git a/EllieHub/Assets/Light/deps.png b/EllieHub/Assets/Light/deps.png
new file mode 100644
index 0000000..b3b2739
Binary files /dev/null and b/EllieHub/Assets/Light/deps.png differ
diff --git a/EllieHub/Assets/Light/docs.png b/EllieHub/Assets/Light/docs.png
new file mode 100644
index 0000000..c221819
Binary files /dev/null and b/EllieHub/Assets/Light/docs.png differ
diff --git a/EllieHub/Assets/Light/ellieupdatericon.ico b/EllieHub/Assets/Light/ellieupdatericon.ico
new file mode 100644
index 0000000..f9e2f35
Binary files /dev/null and b/EllieHub/Assets/Light/ellieupdatericon.ico differ
diff --git a/EllieHub/Assets/Light/home.png b/EllieHub/Assets/Light/home.png
new file mode 100644
index 0000000..bd1875e
Binary files /dev/null and b/EllieHub/Assets/Light/home.png differ
diff --git a/EllieHub/Assets/Light/icon-commands.png b/EllieHub/Assets/Light/icon-commands.png
new file mode 100644
index 0000000..0d9b7ad
Binary files /dev/null and b/EllieHub/Assets/Light/icon-commands.png differ
diff --git a/EllieHub/Assets/Light/icon-embeds.png b/EllieHub/Assets/Light/icon-embeds.png
new file mode 100644
index 0000000..b7f16fe
Binary files /dev/null and b/EllieHub/Assets/Light/icon-embeds.png differ
diff --git a/EllieHub/Assets/Light/icon-link.png b/EllieHub/Assets/Light/icon-link.png
new file mode 100644
index 0000000..f65d578
Binary files /dev/null and b/EllieHub/Assets/Light/icon-link.png differ
diff --git a/EllieHub/Assets/Light/icon-suggest.png b/EllieHub/Assets/Light/icon-suggest.png
new file mode 100644
index 0000000..8c80f1c
Binary files /dev/null and b/EllieHub/Assets/Light/icon-suggest.png differ
diff --git a/EllieHub/Assets/Light/icon-support.png b/EllieHub/Assets/Light/icon-support.png
new file mode 100644
index 0000000..29e47ce
Binary files /dev/null and b/EllieHub/Assets/Light/icon-support.png differ
diff --git a/EllieHub/Assets/Light/terminal.png b/EllieHub/Assets/Light/terminal.png
new file mode 100644
index 0000000..9865ea5
Binary files /dev/null and b/EllieHub/Assets/Light/terminal.png differ
diff --git a/EllieHub/Assets/Unused/addbot.png b/EllieHub/Assets/Unused/addbot.png
new file mode 100644
index 0000000..fd90daa
Binary files /dev/null and b/EllieHub/Assets/Unused/addbot.png differ
diff --git a/EllieHub/Assets/Unused/avalonia-logo.ico b/EllieHub/Assets/Unused/avalonia-logo.ico
new file mode 100644
index 0000000..da8d49f
Binary files /dev/null and b/EllieHub/Assets/Unused/avalonia-logo.ico differ
diff --git a/EllieHub/Assets/Unused/black_home.png b/EllieHub/Assets/Unused/black_home.png
new file mode 100644
index 0000000..0a561cb
Binary files /dev/null and b/EllieHub/Assets/Unused/black_home.png differ
diff --git a/EllieHub/Assets/Unused/config2.png b/EllieHub/Assets/Unused/config2.png
new file mode 100644
index 0000000..de715c9
Binary files /dev/null and b/EllieHub/Assets/Unused/config2.png differ
diff --git a/EllieHub/Assets/Unused/config_active.png b/EllieHub/Assets/Unused/config_active.png
new file mode 100644
index 0000000..87a17f4
Binary files /dev/null and b/EllieHub/Assets/Unused/config_active.png differ
diff --git a/EllieHub/Assets/Unused/delete.png b/EllieHub/Assets/Unused/delete.png
new file mode 100644
index 0000000..d20b20d
Binary files /dev/null and b/EllieHub/Assets/Unused/delete.png differ
diff --git a/EllieHub/Assets/Unused/install_bg.png b/EllieHub/Assets/Unused/install_bg.png
new file mode 100644
index 0000000..fd872fd
Binary files /dev/null and b/EllieHub/Assets/Unused/install_bg.png differ
diff --git a/EllieHub/Assets/Unused/setup.png b/EllieHub/Assets/Unused/setup.png
new file mode 100644
index 0000000..cbb71ef
Binary files /dev/null and b/EllieHub/Assets/Unused/setup.png differ
diff --git a/EllieHub/Assets/Unused/setup_active.png b/EllieHub/Assets/Unused/setup_active.png
new file mode 100644
index 0000000..601c926
Binary files /dev/null and b/EllieHub/Assets/Unused/setup_active.png differ
diff --git a/EllieHub/Assets/Unused/terminal_active.png b/EllieHub/Assets/Unused/terminal_active.png
new file mode 100644
index 0000000..346c3a1
Binary files /dev/null and b/EllieHub/Assets/Unused/terminal_active.png differ
diff --git a/EllieHub/Assets/Unused/unknown.png b/EllieHub/Assets/Unused/unknown.png
new file mode 100644
index 0000000..1a38b72
Binary files /dev/null and b/EllieHub/Assets/Unused/unknown.png differ
diff --git a/EllieHub/Assets/Unused/white_home.png b/EllieHub/Assets/Unused/white_home.png
new file mode 100644
index 0000000..853b761
Binary files /dev/null and b/EllieHub/Assets/Unused/white_home.png differ
diff --git a/EllieHub/Assets/ellie.png b/EllieHub/Assets/ellie.png
new file mode 100644
index 0000000..c338b09
Binary files /dev/null and b/EllieHub/Assets/ellie.png differ
diff --git a/EllieHub/Assets/ko-fi.webp b/EllieHub/Assets/ko-fi.webp
new file mode 100644
index 0000000..0a12895
Binary files /dev/null and b/EllieHub/Assets/ko-fi.webp differ
diff --git a/EllieHub/Assets/patreon.png b/EllieHub/Assets/patreon.png
new file mode 100644
index 0000000..c6da3af
Binary files /dev/null and b/EllieHub/Assets/patreon.png differ
diff --git a/EllieHub/Assets/paypal.png b/EllieHub/Assets/paypal.png
new file mode 100644
index 0000000..52af164
Binary files /dev/null and b/EllieHub/Assets/paypal.png differ
diff --git a/EllieHub/Common/AppConstants.cs b/EllieHub/Common/AppConstants.cs
new file mode 100644
index 0000000..044f70d
--- /dev/null
+++ b/EllieHub/Common/AppConstants.cs
@@ -0,0 +1,17 @@
+namespace EllieHub.Common;
+
+///
+/// Defines the constants used throughout the whole application.
+///
+public static class AppConstants
+{
+ ///
+ /// Defines the location of the default image for the bot avatar.
+ ///
+ public const string BotAvatarUri = "avares://EllieHub/Assets/ellie.png";
+
+ ///
+ /// The name for an that does not automatically follow redirect responses.
+ ///
+ public const string NoRedirectClient = "NoRedirect";
+}
\ No newline at end of file
diff --git a/EllieHub/Common/AppResources.cs b/EllieHub/Common/AppResources.cs
new file mode 100644
index 0000000..fe8b56a
--- /dev/null
+++ b/EllieHub/Common/AppResources.cs
@@ -0,0 +1,127 @@
+using Avalonia.Controls;
+using Avalonia.Media.Imaging;
+using Avalonia.Media.Immutable;
+
+namespace EllieHub.Common;
+
+///
+/// Defines names of resources for the application.
+///
+public static class AppResources
+{
+ #region Images
+
+ ///
+ /// Resource type:
+ ///
+ public const string EllieAvatar = "EllieAvatar";
+
+ ///
+ /// Resource type:
+ ///
+ public const string PaypalIcon = "PaypalIcon";
+
+ ///
+ /// Resource type:
+ ///
+ public const string PatreonIcon = "PatreonIcon";
+
+ ///
+ /// Resource type:
+ ///
+ public const string CheckForUpdateIcon = "CheckForUpdateIcon";
+
+ ///
+ /// Resource type:
+ ///
+ public const string ConfigIcon = "ConfigIcon";
+
+ ///
+ /// Resource type:
+ ///
+ public const string DependenciesIcon = "DependenciesIcon";
+
+ ///
+ /// Resource type:
+ ///
+ public const string DocumentationIcon = "DocumentationIcon";
+
+ ///
+ /// Resource type:
+ ///
+ public const string HomeIcon = "HomeIcon";
+
+ ///
+ /// Resource type:
+ ///
+ public const string EmbedsIcon = "EmbedsIcon";
+
+ ///
+ /// Resource type:
+ ///
+ public const string UrlIcon = "UrlIcon";
+
+ ///
+ /// Resource type:
+ ///
+ public const string SuggestionIcon = "SuggestionIcon";
+
+ ///
+ /// Resource type:
+ ///
+ public const string DiscordIcon = "DiscordIcon";
+
+ ///
+ /// Resource type:
+ ///
+ public const string EllieHubIcon = "EllieHubIcon";
+
+ ///
+ /// Resource type:
+ ///
+ public const string EllieHubImage = "EllieHubImage";
+
+ ///
+ /// Resource type:
+ ///
+ public const string TerminalIcon = "TerminalIcon";
+
+ #endregion
+
+ #region Color Brushes
+
+ ///
+ /// Resource type:
+ ///
+ public const string LightBackground = "LightBackground";
+
+
+ ///
+ /// Resource type:
+ ///
+ public const string MediumBackground = "MediumBackground";
+
+
+ ///
+ /// Resource type:
+ ///
+ public const string HeavyBackground = "HeavyBackground";
+
+
+ ///
+ /// Resource type:
+ ///
+ public const string BotSelectionColor = "BotSelectionColor";
+
+ ///
+ /// Resource type:
+ ///
+ public const string DependencyInstall = "DependencyInstall";
+
+ ///
+ /// Resource type:
+ ///
+ public const string DependencyUpdate = "DependencyUpdate";
+
+ #endregion
+}
\ No newline at end of file
diff --git a/EllieHub/Common/AppStatics.cs b/EllieHub/Common/AppStatics.cs
new file mode 100644
index 0000000..1a9dfa1
--- /dev/null
+++ b/EllieHub/Common/AppStatics.cs
@@ -0,0 +1,73 @@
+using Avalonia.Media;
+using Avalonia.Media.Immutable;
+using Avalonia.Platform.Storage;
+using System.Text.RegularExpressions;
+
+namespace EllieHub.Common;
+
+///
+/// Defines the application's environment data.
+///
+public static partial class AppStatics
+{
+ ///
+ /// Defines the default location where the updater configuration and bot instances are stored.
+ ///
+#if DEBUG
+ public static string AppDefaultConfigDirectoryUri { get; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), "EllieHubDebug");
+#else
+ public static string AppDefaultConfigDirectoryUri { get; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "EllieHub");
+#endif
+
+ ///
+ /// Defines the default location where the bot instances are stored.
+ ///
+ public static string AppDefaultBotDirectoryUri { get; } = Path.Combine(AppDefaultConfigDirectoryUri, "Bots");
+
+ ///
+ /// Defines the default location where the backups of bot instances are stored.
+ ///
+ public static string AppDefaultBotBackupDirectoryUri { get; } = Path.Combine(AppDefaultConfigDirectoryUri, "Backups");
+
+ ///
+ /// Defines the default location where the logs of bot instances are stored.
+ ///
+ public static string AppDefaultLogDirectoryUri { get; } = Path.Combine(AppDefaultConfigDirectoryUri, "Logs");
+
+ ///
+ /// Defines the location of the application's configuration file.
+ ///
+ public static string AppConfigUri { get; } = Path.Combine(AppDefaultConfigDirectoryUri, "config.json");
+
+ ///
+ /// Defines the location of the application's dependencies.
+ ///
+ public static string AppDepsUri { get; } = Path.Combine(AppDefaultConfigDirectoryUri, "Dependencies");
+
+ ///
+ /// Defines a transparent color brush.
+ ///
+ public static ImmutableSolidColorBrush TransparentColorBrush { get; } = new(Colors.Transparent);
+
+ ///
+ /// Represents the image formats supported by the views of this application.
+ ///
+ public static FilePickerOpenOptions ImageFilePickerOptions { get; } = new()
+ {
+ AllowMultiple = false,
+ FileTypeFilter = new FilePickerFileType[]
+ {
+ new("Image") { Patterns = new[] { "*.png", "*.jpg", "*.jpeg", "*.gif", "*.webp" } },
+ new("All") { Patterns = new[] { "*.*" } }
+ }
+ };
+
+ ///
+ /// Matches the version of Ffmpeg from its CLI output.
+ ///
+ /// Pattern: ^(?:\S+\s+\D*?){2}(git\S+|[\d\.]+)
+ public static Regex FfmpegVersionRegex { get; } = FfmpegVersionRegexGenerator();
+
+ [GeneratedRegex(@"^(?:\S+\s+\D*?){2}(git\S+|[\d\.]+)", RegexOptions.Compiled)]
+ private static partial Regex FfmpegVersionRegexGenerator();
+}
\ No newline at end of file
diff --git a/EllieHub/Common/DialogType.cs b/EllieHub/Common/DialogType.cs
new file mode 100644
index 0000000..1207ec3
--- /dev/null
+++ b/EllieHub/Common/DialogType.cs
@@ -0,0 +1,22 @@
+namespace EllieHub.Common;
+
+///
+/// Defines the available types of dialog windows.
+///
+public enum DialogType
+{
+ ///
+ /// The dialog box notifies the user about something.
+ ///
+ Notification,
+
+ ///
+ /// The dialog box notifies the user of a non-fatal error.
+ ///
+ Warning,
+
+ ///
+ /// The dialog box notifies the user of a fatal error.
+ ///
+ Error
+}
\ No newline at end of file
diff --git a/EllieHub/Common/Utilities.cs b/EllieHub/Common/Utilities.cs
new file mode 100644
index 0000000..c6c7cf9
--- /dev/null
+++ b/EllieHub/Common/Utilities.cs
@@ -0,0 +1,215 @@
+using Avalonia.Platform;
+using SkiaSharp;
+using System.ComponentModel;
+using System.Diagnostics;
+using System.Diagnostics.CodeAnalysis;
+
+namespace EllieHub.Common;
+
+///
+/// Miscellaneous utility methods.
+///
+internal static class Utilities
+{
+ private static readonly string _programVerifier = (Environment.OSVersion.Platform is PlatformID.Win32NT) ? "where" : "which";
+ private static readonly string _envPathSeparator = (Environment.OSVersion.Platform is PlatformID.Win32NT) ? ";" : ":";
+ private static readonly EnvironmentVariableTarget _envTarget = (Environment.OSVersion.Platform is PlatformID.Win32NT)
+ ? EnvironmentVariableTarget.User
+ : EnvironmentVariableTarget.Process;
+
+ ///
+ /// Loads an image embeded with this application.
+ ///
+ /// An uri that starts with "avares://"
+ /// Valid uris must start with "avares://".
+ /// The embeded image or the default bot avatar placeholder.
+ /// Occurs when the embeded resource does not exist.
+ public static SKBitmap LoadEmbededImage(string? uri = default)
+ {
+ return (string.IsNullOrWhiteSpace(uri) || !uri.StartsWith("avares://"))
+ ? SKBitmap.Decode(AssetLoader.Open(new Uri(AppConstants.BotAvatarUri)))
+ : SKBitmap.Decode(AssetLoader.Open(new Uri(uri)));
+ }
+
+ ///
+ /// Loads the image at the specified location or the bot avatar placeholder if it was not found.
+ ///
+ /// The absolute path to the image file or to get the avatar placeholder.
+ /// This fallsback to if doesn't point to a valid image file.
+ /// The requested image or the default bot avatar placeholder.
+ public static SKBitmap LoadLocalImage(string? uri = default)
+ {
+ return (File.Exists(uri))
+ ? SKBitmap.Decode(uri)
+ : LoadEmbededImage(uri);
+ }
+
+ ///
+ /// Safely casts an to a .
+ ///
+ /// The type to cast to.
+ /// The object to be cast.
+ /// The cast object, or is casting failed.
+ /// if the object was successfully cast, otherwise.
+ public static bool TryCastTo(object? obj, [MaybeNullWhen(false)] out T castObject)
+ {
+ if (obj is T result)
+ {
+ castObject = result;
+ return true;
+ }
+
+ castObject = default;
+ return false;
+ }
+
+ ///
+ /// Starts the specified program in the background.
+ ///
+ ///
+ /// The name of the program in the PATH environment variable,
+ /// or the absolute path to its executable.
+ ///
+ /// The arguments to the program.
+ /// The process of the specified program.
+ ///
+ ///
+ /// Occurs when does not exist.
+ /// Occurs when the process fails to execute.
+ public static Process StartProcess(string program, string arguments = "")
+ {
+ ArgumentException.ThrowIfNullOrEmpty(program, nameof(program));
+ ArgumentNullException.ThrowIfNull(arguments, nameof(arguments));
+
+ return Process.Start(new ProcessStartInfo()
+ {
+ FileName = program,
+ Arguments = arguments,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ }) ?? throw new InvalidOperationException($"Failed spawing process for: {program} {arguments}");
+ }
+
+ ///
+ /// Checks if a program exists.
+ ///
+ /// The name of the program.
+ /// The cancellation token.
+ /// if the program exists, otherwise.
+ ///
+ ///
+ public static async ValueTask ProgramExistsAsync(string programName, CancellationToken cToken = default)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(programName, nameof(programName));
+
+ using var process = StartProcess(_programVerifier, programName);
+ return !string.IsNullOrWhiteSpace(await process.StandardOutput.ReadToEndAsync(cToken));
+ }
+
+ ///
+ /// Safely deletes a file.
+ ///
+ /// The absolute path to the file.
+ /// if the file was deleted, otherwise.
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static bool TryDeleteFile(string fileUri)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(fileUri, nameof(fileUri));
+
+ if (!File.Exists(fileUri))
+ return false;
+
+ File.Delete(fileUri);
+ return true;
+ }
+
+ ///
+ /// Safely deletes a directory.
+ ///
+ /// The absolute path to the directory.
+ /// if the directory was deleted, otherwise.
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static bool TryDeleteDirectory(string directoryUri)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(directoryUri, nameof(directoryUri));
+
+ if (!Directory.Exists(directoryUri))
+ return false;
+
+ Directory.Delete(directoryUri, true);
+ return true;
+ }
+
+ ///
+ /// Checks if this application can write to .
+ ///
+ /// The absolute path to a directory.
+ /// if writing is allowed, otherwise.
+ ///
+ ///
+ public static bool CanWriteTo(string directoryUri)
+ {
+ var tempFileUri = Path.Combine(directoryUri, $"{Guid.NewGuid()}.tmp");
+
+ try
+ {
+ using var fileStream = File.Create(tempFileUri);
+ return true;
+ }
+ catch (UnauthorizedAccessException)
+ {
+ return false;
+ }
+ finally
+ {
+ TryDeleteFile(tempFileUri);
+ }
+ }
+
+ ///
+ /// Adds a directory path to the PATH environment variable.
+ ///
+ /// The absolute path to a directory.
+ ///
+ /// On Windows, this needs to be called once and the dependencies will be available for the user forever.
+ /// On Unix systems, we can only add to the PATH on a process basis, so this needs to be called at least once everytime the application is opened.
+ ///
+ /// if got successfully added to the PATH envar, otherwise.
+ ///
+ ///
+ public static bool AddPathToPATHEnvar(string directoryUri)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(directoryUri, nameof(directoryUri));
+
+ if (File.Exists(directoryUri))
+ throw new ArgumentException("Parameter must point to a directory, not a file.", nameof(directoryUri));
+
+ var envPathValue = Environment.GetEnvironmentVariable("PATH", _envTarget) ?? string.Empty;
+
+ // If directoryPath is already in the PATH envar, don't add it again.
+ if (envPathValue.Contains(directoryUri, StringComparison.Ordinal))
+ return false;
+
+ var newPathEnvValue = envPathValue + _envPathSeparator + directoryUri;
+
+ // Add path to Windows' user envar, so it persists across reboots.
+ if (Environment.OSVersion.Platform is PlatformID.Win32NT)
+ Environment.SetEnvironmentVariable("PATH", newPathEnvValue, EnvironmentVariableTarget.User);
+
+ // Add path to the current process' envar, so the updater can see the dependencies.
+ Environment.SetEnvironmentVariable("PATH", newPathEnvValue, EnvironmentVariableTarget.Process);
+
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/Common/WindowConstants.cs b/EllieHub/Common/WindowConstants.cs
new file mode 100644
index 0000000..770c1c1
--- /dev/null
+++ b/EllieHub/Common/WindowConstants.cs
@@ -0,0 +1,37 @@
+namespace EllieHub.Common;
+
+///
+/// Defines constants to be used inside views.
+///
+public static class WindowConstants
+{
+ ///
+ /// Defines the default width of the window.
+ ///
+ public const string DefaultWindowWidth = "885";
+
+ ///
+ /// Defines the default height of the window.
+ ///
+ public const string DefaultWindowHeight = "570";
+
+ ///
+ /// Defines the minimum height of the window.
+ ///
+ public const string MinWindowWidth = "550";
+
+ ///
+ /// Defines the minimum height of the window.
+ ///
+ public const string MinWindowHeight = "500";
+
+ ///
+ /// Defines the default window title.
+ ///
+ public const string WindowTitle = "Ellie Hub";
+
+ ///
+ /// Defines the message that should be shown when a view's parameterless constructor should not be used.
+ ///
+ public const string DesignerCtorWarning = "This constructor exists to satisfy Avalonia's designer. Please, use the parameterized constructor instead.";
+}
\ No newline at end of file
diff --git a/EllieHub/DesignData/Common/DesignStatics.cs b/EllieHub/DesignData/Common/DesignStatics.cs
new file mode 100644
index 0000000..b307c18
--- /dev/null
+++ b/EllieHub/DesignData/Common/DesignStatics.cs
@@ -0,0 +1,18 @@
+using Avalonia;
+using Avalonia.Controls;
+
+namespace EllieHub.DesignData.Common;
+
+///
+/// Defines objects useful at design-time.
+///
+internal static class DesignStatics
+{
+ ///
+ /// Provides the services necessary for design-time rendering of views.
+ ///
+ /// This property is when the application is not in design mode.
+ internal static IServiceProvider Services { get; } = (Design.IsDesignMode)
+ ? (Application.Current as App)!.Services
+ : null!;
+}
\ No newline at end of file
diff --git a/EllieHub/DesignData/Controls/DesignBotConfigViewModel.cs b/EllieHub/DesignData/Controls/DesignBotConfigViewModel.cs
new file mode 100644
index 0000000..1af830b
--- /dev/null
+++ b/EllieHub/DesignData/Controls/DesignBotConfigViewModel.cs
@@ -0,0 +1,30 @@
+using Microsoft.Extensions.DependencyInjection;
+using EllieHub.DesignData.Common;
+using EllieHub.Services.Abstractions;
+using EllieHub.Services.Mocks;
+using EllieHub.ViewModels.Controls;
+using EllieHub.Views.Windows;
+
+namespace EllieHub.DesignData.Controls;
+
+///
+/// Mock view-model for .
+///
+public sealed class DesignBotConfigViewModel : BotConfigViewModel
+{
+ ///
+ /// Creates a mock to be used at design-time.
+ ///
+ public DesignBotConfigViewModel() : base(
+ DesignStatics.Services.GetRequiredService(),
+ DesignStatics.Services.GetRequiredService(),
+ DesignStatics.Services.GetRequiredService(),
+ DesignStatics.Services.GetRequiredService(),
+ DesignStatics.Services.GetRequiredService(),
+ DesignStatics.Services.GetRequiredService(),
+ DesignStatics.Services.GetRequiredService(),
+ DesignStatics.Services.GetRequiredService()
+ )
+ {
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/DesignData/Controls/DesignConfigViewModel.cs b/EllieHub/DesignData/Controls/DesignConfigViewModel.cs
new file mode 100644
index 0000000..ddee175
--- /dev/null
+++ b/EllieHub/DesignData/Controls/DesignConfigViewModel.cs
@@ -0,0 +1,31 @@
+using Microsoft.Extensions.DependencyInjection;
+using EllieHub.DesignData.Common;
+using EllieHub.Services.Abstractions;
+using EllieHub.Services.Mocks;
+using EllieHub.ViewModels.Controls;
+using EllieHub.ViewModels.Windows;
+using EllieHub.Views.Windows;
+
+namespace EllieHub.DesignData.Controls;
+
+///
+/// Mock view-model for .
+///
+public sealed class DesignConfigViewModel : ConfigViewModel
+{
+ ///
+ /// Creates a mock to be used at design-time.
+ ///
+ public DesignConfigViewModel() : base(
+ DesignStatics.Services.GetRequiredService(),
+ DesignStatics.Services.GetRequiredService(),
+ DesignStatics.Services.GetRequiredService(),
+ DesignStatics.Services.GetRequiredService(),
+ DesignStatics.Services.GetRequiredService(),
+ DesignStatics.Services.GetRequiredService(),
+ DesignStatics.Services.GetRequiredService(),
+ DesignStatics.Services.GetRequiredService()
+ )
+ {
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/DesignData/Controls/DesignDependencyButtonViewModel.cs b/EllieHub/DesignData/Controls/DesignDependencyButtonViewModel.cs
new file mode 100644
index 0000000..04e3507
--- /dev/null
+++ b/EllieHub/DesignData/Controls/DesignDependencyButtonViewModel.cs
@@ -0,0 +1,19 @@
+using Microsoft.Extensions.DependencyInjection;
+using EllieHub.DesignData.Common;
+using EllieHub.ViewModels.Controls;
+using EllieHub.Views.Windows;
+
+namespace EllieHub.DesignData.Controls;
+
+///
+/// Mock view-model for .
+///
+public sealed class DesignDependencyButtonViewModel : DependencyButtonViewModel
+{
+ ///
+ /// Creates a mock to be used at design-time.
+ ///
+ public DesignDependencyButtonViewModel() : base(DesignStatics.Services.GetRequiredService())
+ {
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/DesignData/Controls/DesignFakeConsoleViewModel.cs b/EllieHub/DesignData/Controls/DesignFakeConsoleViewModel.cs
new file mode 100644
index 0000000..fd73a8b
--- /dev/null
+++ b/EllieHub/DesignData/Controls/DesignFakeConsoleViewModel.cs
@@ -0,0 +1,15 @@
+using EllieHub.ViewModels.Controls;
+
+namespace EllieHub.DesignData.Controls;
+
+///
+/// Mock view-model for .
+///
+public sealed class DesignFakeConsoleViewModel : FakeConsoleViewModel
+{
+ ///
+ /// Creates a mock to be used at design-time.
+ ///
+ public DesignFakeConsoleViewModel() : base()
+ => Watermark = "Sample watermark.";
+}
\ No newline at end of file
diff --git a/EllieHub/DesignData/Controls/DesignHomeViewModel.cs b/EllieHub/DesignData/Controls/DesignHomeViewModel.cs
new file mode 100644
index 0000000..4743724
--- /dev/null
+++ b/EllieHub/DesignData/Controls/DesignHomeViewModel.cs
@@ -0,0 +1,10 @@
+using EllieHub.ViewModels.Controls;
+
+namespace EllieHub.DesignData.Controls;
+
+///
+/// Mock view-model for .
+///
+public sealed class DesignHomeViewModel : HomeViewModel
+{
+}
\ No newline at end of file
diff --git a/EllieHub/DesignData/Controls/DesignLateralBarViewModel.cs b/EllieHub/DesignData/Controls/DesignLateralBarViewModel.cs
new file mode 100644
index 0000000..c03049f
--- /dev/null
+++ b/EllieHub/DesignData/Controls/DesignLateralBarViewModel.cs
@@ -0,0 +1,19 @@
+using Microsoft.Extensions.DependencyInjection;
+using EllieHub.DesignData.Common;
+using EllieHub.Services.Mocks;
+using EllieHub.ViewModels.Controls;
+
+namespace EllieHub.DesignData.Controls;
+
+///
+/// Mock view-model for .
+///
+public sealed class DesignLateralBarViewModel : LateralBarViewModel
+{
+ ///
+ /// Creates a mock to be used at design-time.
+ ///
+ public DesignLateralBarViewModel() : base(DesignStatics.Services.GetRequiredService())
+ {
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/DesignData/Controls/DesignUriInputBarViewModel.cs b/EllieHub/DesignData/Controls/DesignUriInputBarViewModel.cs
new file mode 100644
index 0000000..8343c4e
--- /dev/null
+++ b/EllieHub/DesignData/Controls/DesignUriInputBarViewModel.cs
@@ -0,0 +1,19 @@
+using Avalonia.Platform.Storage;
+using Microsoft.Extensions.DependencyInjection;
+using EllieHub.DesignData.Common;
+using EllieHub.ViewModels.Controls;
+
+namespace EllieHub.DesignData.Controls;
+
+///
+/// Mock view-model for .
+///
+public sealed class DesignUriInputBarViewModel : UriInputBarViewModel
+{
+ ///
+ /// Creates a mock to be used at design-time.
+ ///
+ public DesignUriInputBarViewModel() : base(DesignStatics.Services.GetRequiredService())
+ {
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/DesignData/Windows/DesignAboutMeViewModel.cs b/EllieHub/DesignData/Windows/DesignAboutMeViewModel.cs
new file mode 100644
index 0000000..6535212
--- /dev/null
+++ b/EllieHub/DesignData/Windows/DesignAboutMeViewModel.cs
@@ -0,0 +1,10 @@
+using EllieHub.ViewModels.Windows;
+
+namespace EllieHub.DesignData.Windows;
+
+///
+/// Mock view-model for .
+///
+public sealed class DesignAboutMeViewModel : AboutMeViewModel
+{
+}
\ No newline at end of file
diff --git a/EllieHub/DesignData/Windows/DesignAppViewModel.cs b/EllieHub/DesignData/Windows/DesignAppViewModel.cs
new file mode 100644
index 0000000..a48a0fe
--- /dev/null
+++ b/EllieHub/DesignData/Windows/DesignAppViewModel.cs
@@ -0,0 +1,22 @@
+using Microsoft.Extensions.DependencyInjection;
+using EllieHub.DesignData.Common;
+using EllieHub.ViewModels.Controls;
+using EllieHub.ViewModels.Windows;
+
+namespace EllieHub.DesignData.Windows;
+
+///
+/// Mock view-model for .
+///
+public sealed class DesignAppViewModel : AppViewModel
+{
+ ///
+ /// Creates a mock to be used at design-time.
+ ///
+ public DesignAppViewModel() : base(
+ DesignStatics.Services.GetRequiredService(),
+ DesignStatics.Services.GetRequiredService()
+ )
+ {
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/DesignData/Windows/DesignUpdateViewModel.cs b/EllieHub/DesignData/Windows/DesignUpdateViewModel.cs
new file mode 100644
index 0000000..2abed0b
--- /dev/null
+++ b/EllieHub/DesignData/Windows/DesignUpdateViewModel.cs
@@ -0,0 +1,10 @@
+using EllieHub.ViewModels.Windows;
+
+namespace EllieHub.DesignData.Windows;
+
+///
+/// Mock view-model for .
+///
+public sealed class DesignUpdateViewModel : UpdateViewModel
+{
+}
\ No newline at end of file
diff --git a/EllieHub/EllieHub.csproj b/EllieHub/EllieHub.csproj
new file mode 100644
index 0000000..9811eb9
--- /dev/null
+++ b/EllieHub/EllieHub.csproj
@@ -0,0 +1,62 @@
+
+
+
+ WinExe
+ net7.0
+ latest
+ latest
+ enable
+ enable
+ Nullable
+ True
+ True
+ True
+ True
+ Assets/Light/ellieupdatericon.ico
+
+
+ true
+ embedded
+
+
+ 1.0.0.0
+
+
+ app.manifest
+ true
+
+
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ AppView.axaml
+
+
+
\ No newline at end of file
diff --git a/EllieHub/Enums/DependencyStatus.cs b/EllieHub/Enums/DependencyStatus.cs
new file mode 100644
index 0000000..9b809ad
--- /dev/null
+++ b/EllieHub/Enums/DependencyStatus.cs
@@ -0,0 +1,32 @@
+namespace EllieHub.Enums;
+
+///
+/// Defines the possible status for a dependency.
+///
+public enum DependencyStatus
+{
+ ///
+ /// The dependency is available for installation.
+ ///
+ Install,
+
+ ///
+ /// The dependency is installed and up-to-date.
+ ///
+ Installed,
+
+ ///
+ /// The dependency is in the process of being updated.
+ ///
+ Updating,
+
+ ///
+ /// The dependency has an update available.
+ ///
+ Update,
+
+ ///
+ /// The dependency is currently being checked for updates.
+ ///
+ Checking
+}
\ No newline at end of file
diff --git a/EllieHub/Enums/DependencyType.cs b/EllieHub/Enums/DependencyType.cs
new file mode 100644
index 0000000..2789fde
--- /dev/null
+++ b/EllieHub/Enums/DependencyType.cs
@@ -0,0 +1,17 @@
+namespace EllieHub.Enums;
+
+///
+/// Defines the types of dependencies available to install.
+///
+public enum DependencyType
+{
+ ///
+ /// Ffmpeg.
+ ///
+ Ffmpeg,
+
+ ///
+ /// Yt-dlp.
+ ///
+ Ytdlp
+}
\ No newline at end of file
diff --git a/EllieHub/Enums/ThemeType.cs b/EllieHub/Enums/ThemeType.cs
new file mode 100644
index 0000000..96a7097
--- /dev/null
+++ b/EllieHub/Enums/ThemeType.cs
@@ -0,0 +1,22 @@
+namespace EllieHub.Enums;
+
+///
+/// The types of themes available.
+///
+public enum ThemeType
+{
+ ///
+ /// Either Light or Dark, according to the OS preferences.
+ ///
+ Auto,
+
+ ///
+ /// Light theme.
+ ///
+ Light,
+
+ ///
+ /// Dark theme.
+ ///
+ Dark
+}
\ No newline at end of file
diff --git a/EllieHub/Extensions/HttpClientExt.cs b/EllieHub/Extensions/HttpClientExt.cs
new file mode 100644
index 0000000..5263f1e
--- /dev/null
+++ b/EllieHub/Extensions/HttpClientExt.cs
@@ -0,0 +1,41 @@
+using System.Text.Json;
+
+namespace EllieHub.Extensions;
+
+///
+/// Defines extension methods for .
+///
+public static class HttpClientExt
+{
+ ///
+ /// Sends a GET request to an API at the specified and returns a Json deserialized response.
+ ///
+ /// The type to be returned.
+ /// This http client.
+ /// The API endpoint to be called.
+ /// The cancellation token.
+ /// A response.
+ /// Occurs when the deserialization fails.
+ public static async Task CallApiAsync(this HttpClient http, string endpoint, CancellationToken cToken = default)
+ {
+ var responseString = await http.GetStringAsync(endpoint, cToken);
+
+ return JsonSerializer.Deserialize(responseString)
+ ?? throw new InvalidOperationException($"Could not deserialize response to {nameof(T)}.");
+ }
+
+ ///
+ /// Checks if a request to the specified returns a successful HTTP response.
+ ///
+ /// This http client.
+ /// The url to check.
+ /// The cancellation token.
+ /// if the is valid, otherwise.
+ public static async Task IsUrlValidAsync(this HttpClient http, string url, CancellationToken cToken = default)
+ {
+ var request = new HttpRequestMessage(HttpMethod.Head, url);
+ var response = await http.SendAsync(request, cToken);
+
+ return response.IsSuccessStatusCode;
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/Extensions/IServiceCollectionExt.cs b/EllieHub/Extensions/IServiceCollectionExt.cs
new file mode 100644
index 0000000..785105d
--- /dev/null
+++ b/EllieHub/Extensions/IServiceCollectionExt.cs
@@ -0,0 +1,91 @@
+using Avalonia.Controls;
+using Microsoft.Extensions.DependencyInjection;
+using EllieHub.Models.Config;
+using EllieHub.Services;
+using EllieHub.Services.Abstractions;
+using EllieHub.Services.Mocks;
+using EllieHub.Views.Windows;
+using ReactiveUI;
+using System.Reflection;
+using System.Text.Json;
+
+namespace EllieHub.Extensions;
+
+///
+/// Defines extension methods for .
+///
+public static class IServiceCollectionExt
+{
+ ///
+ /// Registers all views and view-models in the provided to this service collection.
+ ///
+ /// This service collection.
+ /// The assembly to get the views and view-models from.
+ /// This service collection with the views and view-models added.
+ public static IServiceCollection RegisterViewsAndViewModels(this IServiceCollection serviceCollection, Assembly assembly)
+ {
+ var viewModelPairs = assembly.GetTypes()
+ .Where(x => !x.IsAbstract && x.IsAssignableTo(typeof(IViewFor)))
+ .Select(x => (ViewType: x, ViewModelType: x.GetInterface(typeof(IViewFor<>).Name)!.GenericTypeArguments[0]));
+
+ foreach (var (viewType, viewModelType) in viewModelPairs)
+ {
+ serviceCollection.AddSingleton(viewType);
+ serviceCollection.AddTransient(viewModelType);
+ }
+
+ return serviceCollection;
+ }
+
+ ///
+ /// Registers the application's services.
+ ///
+ /// This service collection.
+ /// This service collection with the services added.
+ public static IServiceCollection RegisterServices(this IServiceCollection serviceCollection)
+ {
+ // Design-time
+ if (Design.IsDesignMode)
+ {
+ serviceCollection.AddTransient();
+ serviceCollection.AddTransient();
+ }
+
+ // Internal
+ serviceCollection.AddMemoryCache();
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton(x => x.GetRequiredService().StorageProvider);
+
+ // Web requests
+ serviceCollection.AddHttpClient();
+ serviceCollection.AddHttpClient(AppConstants.NoRedirectClient) // Client that doesn't allow automatic reditections
+ .ConfigureHttpMessageHandlerBuilder(builder => builder.PrimaryHandler = new HttpClientHandler() { AllowAutoRedirect = false });
+
+ // App settings
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton();
+ serviceCollection.AddSingleton(_ =>
+ (File.Exists(AppStatics.AppConfigUri))
+ ? JsonSerializer.Deserialize(File.ReadAllText(AppStatics.AppConfigUri)) ?? new()
+ : new()
+ );
+
+ // Dependency resolvers
+ serviceCollection.AddSingleton();
+ serviceCollection.AddTransient();
+
+ // Platform-dependent services
+ if (OperatingSystem.IsWindows())
+ serviceCollection.AddSingleton();
+ else if (OperatingSystem.IsLinux())
+ serviceCollection.AddSingleton();
+ else if (OperatingSystem.IsMacOS())
+ serviceCollection.AddSingleton();
+ else
+ serviceCollection.AddSingleton();
+
+ return serviceCollection;
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/Extensions/IServiceProviderExt.cs b/EllieHub/Extensions/IServiceProviderExt.cs
new file mode 100644
index 0000000..f488bd5
--- /dev/null
+++ b/EllieHub/Extensions/IServiceProviderExt.cs
@@ -0,0 +1,31 @@
+using Microsoft.Extensions.DependencyInjection;
+
+namespace EllieHub.Extensions;
+
+///
+/// Defines extension methods for .
+///
+public static class IServiceProviderExt
+{
+ ///
+ /// Gets service of type from the .
+ ///
+ /// The type of service object to get.
+ /// The to retrieve the service object from.
+ ///
+ /// Do not use abstract types in the type argument!
+ /// A service object of type .
+ /// Occurs when or are .
+ /// Occurs when there is no concrete service of type or when the arguments are wrong.
+ public static T GetParameterizedService(this IServiceProvider serviceProvider, params object[] arguments)
+ {
+ ArgumentNullException.ThrowIfNull(serviceProvider, nameof(serviceProvider));
+ ArgumentNullException.ThrowIfNull(arguments, nameof(arguments));
+
+ var result = ActivatorUtilities.CreateInstance(serviceProvider, arguments);
+
+ return (result is null)
+ ? throw new InvalidOperationException($"There is no service of type {nameof(T)} or the arguments were incorrect.")
+ : result;
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/Extensions/WindowExt.cs b/EllieHub/Extensions/WindowExt.cs
new file mode 100644
index 0000000..02de951
--- /dev/null
+++ b/EllieHub/Extensions/WindowExt.cs
@@ -0,0 +1,90 @@
+using Avalonia.Controls;
+using Avalonia.Styling;
+using MsBox.Avalonia;
+using MsBox.Avalonia.Dto;
+using MsBox.Avalonia.Enums;
+
+namespace EllieHub.Extensions;
+
+///
+/// Provides extension methods for .
+///
+public static class WindowExt
+{
+ ///
+ /// Shows a dialog window that blocks the main window.
+ ///
+ /// The active window.
+ /// The message to be displayed.
+ /// The type of dialog window to display.
+ /// The icon to be displayed.
+ /// The button type that was pressed.
+ public static Task ShowDialogWindowAsync(this Window activeView, string message, DialogType dialogType = DialogType.Notification, Icon iconType = Icon.None)
+ => ShowDialogWindowAsync(activeView, message, dialogType.ToString(), iconType);
+
+ ///
+ /// Shows a dialog window that blocks the main window.
+ ///
+ /// The active window.
+ /// The message to be displayed.
+ /// The title of the dialog box.
+ /// The icon to be displayed.
+ /// The button type that was pressed.
+ public static Task ShowDialogWindowAsync(this Window activeView, string message, string title, Icon iconType = Icon.None)
+ {
+ var messageparameters = new MessageBoxStandardParams()
+ {
+ ButtonDefinitions = ButtonEnum.Ok,
+ ContentMessage = message,
+ ContentTitle = title,
+ Icon = iconType,
+ WindowIcon = activeView.GetResource(AppResources.EllieHubIcon),
+ MaxWidth = int.Parse(WindowConstants.DefaultWindowWidth) / 1.7,
+ SizeToContent = SizeToContent.WidthAndHeight,
+ ShowInCenter = true,
+ WindowStartupLocation = WindowStartupLocation.CenterOwner
+ };
+
+ return ShowDialogWindowAsync(activeView, messageparameters);
+ }
+
+ ///
+ /// Shows a dialog window that blocks the main window.
+ ///
+ /// The active window.
+ /// The parameters of the message dialog box.
+ /// The button type that was pressed.
+ public static Task ShowDialogWindowAsync(this Window activeView, MessageBoxStandardParams messageParameters)
+ => MessageBoxManager.GetMessageBoxStandard(messageParameters).ShowWindowDialogAsync(activeView);
+
+ ///
+ /// Finds the specified resource by searching up the logical tree and then global styles.
+ ///
+ /// The type of the resource.
+ /// The active window.
+ /// The name of the resource.
+ /// The requested resource.
+ /// Occurs when the resource is not found.
+ /// Occurs when the resource is not of type .
+ public static T GetResource(this Window activeView, string resourceName)
+ => GetResource(activeView, resourceName, activeView.ActualThemeVariant);
+
+ ///
+ /// Finds the specified resource by searching up the logical tree and then global styles.
+ ///
+ /// The type of the resource.
+ /// The active window.
+ /// The name of the resource.
+ /// The UI theme variant the resource belongs to.
+ /// The requested resource.
+ /// Occurs when the resource is not found.
+ /// Occurs when the resource is not of type .
+ public static T GetResource(this Window activeView, string resourceName, ThemeVariant theme)
+ {
+ return (!activeView.TryFindResource(resourceName, theme, out var resource))
+ ? throw new InvalidOperationException($"Resource '{resourceName}' was not found.")
+ : (!Utilities.TryCastTo(resource, out var result))
+ ? throw new InvalidCastException($"Could not convert resource of type '{resource?.GetType()?.FullName}' to '{nameof(T)}'.")
+ : result;
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/Models/Api/EvermeetDownloadInfo.cs b/EllieHub/Models/Api/EvermeetDownloadInfo.cs
new file mode 100644
index 0000000..c07844c
--- /dev/null
+++ b/EllieHub/Models/Api/EvermeetDownloadInfo.cs
@@ -0,0 +1,15 @@
+using System.Text.Json.Serialization;
+
+namespace EllieHub.Models.Api;
+
+///
+/// Represents download information for a component.
+///
+/// The url to download the component.
+/// The size of the package to download, in bytes.
+/// The url to download the cryptographic signature of the component.
+public sealed record EvermeetDownloadInfo(
+ [property: JsonPropertyName("url")] string Url,
+ [property: JsonPropertyName("size")] uint Size,
+ [property: JsonPropertyName("sig")] string SignatureUrl
+);
\ No newline at end of file
diff --git a/EllieHub/Models/Api/EvermeetInfo.cs b/EllieHub/Models/Api/EvermeetInfo.cs
new file mode 100644
index 0000000..ac91065
--- /dev/null
+++ b/EllieHub/Models/Api/EvermeetInfo.cs
@@ -0,0 +1,19 @@
+using System.Text.Json.Serialization;
+
+namespace EllieHub.Models.Api;
+
+///
+/// Represents a response from the "https://evermeet.cx/ffmpeg/info" endpoint.
+///
+/// The name of the component.
+/// The type of the component (snapshot or release).
+/// The version of the component.
+/// The size of the component, in bytes.
+/// The download links to the component, where the key is the desired file format.
+public sealed record EvermeetInfo(
+ [property: JsonPropertyName("name")] string Name,
+ [property: JsonPropertyName("type")] string Type,
+ [property: JsonPropertyName("version")] string Version,
+ [property: JsonPropertyName("size")] uint Size,
+ [property: JsonPropertyName("download")] IReadOnlyDictionary Download
+);
\ No newline at end of file
diff --git a/EllieHub/Models/BotEntry.cs b/EllieHub/Models/BotEntry.cs
new file mode 100644
index 0000000..51b9e47
--- /dev/null
+++ b/EllieHub/Models/BotEntry.cs
@@ -0,0 +1,10 @@
+using EllieHub.ViewModels.Controls;
+
+namespace EllieHub.Models;
+
+///
+/// Represents a bot entry in the .
+///
+/// The Id of the bot.
+/// The information about the bot instance.
+public sealed record BotEntry(Guid Id, BotInstanceInfo BotInfo);
\ No newline at end of file
diff --git a/EllieHub/Models/BotInstanceInfo.cs b/EllieHub/Models/BotInstanceInfo.cs
new file mode 100644
index 0000000..021f294
--- /dev/null
+++ b/EllieHub/Models/BotInstanceInfo.cs
@@ -0,0 +1,11 @@
+namespace EllieHub.Models;
+
+///
+/// Represents the information of a bot instance.
+///
+/// The name of the bot.
+/// The path to the directory where the bot instance is located at.
+/// The position of the bot in the lateral bar.
+/// The version of the bot or if the bot hasn't been downloaded yet.
+/// The path to the bot's avatar image file or is there is none.
+public sealed record BotInstanceInfo(string Name, string InstanceDirectoryUri, uint Position, string? Version = default, string? AvatarUri = default);
\ No newline at end of file
diff --git a/EllieHub/Models/Config/AppConfig.cs b/EllieHub/Models/Config/AppConfig.cs
new file mode 100644
index 0000000..949a972
--- /dev/null
+++ b/EllieHub/Models/Config/AppConfig.cs
@@ -0,0 +1,56 @@
+using EllieHub.Enums;
+using System.Collections.Concurrent;
+
+namespace EllieHub.Models.Config;
+
+///
+/// Represents the settings of the application.
+///
+/// Prefer using in dependency injection, if possible.
+public sealed class AppConfig
+{
+ ///
+ /// The absolute path to the directory where the bot instances are stored.
+ ///
+ public string BotsDirectoryUri { get; set; } = AppStatics.AppDefaultBotDirectoryUri;
+
+ ///
+ /// The absolute path to the directory where the bot instances are backed up.
+ ///
+ public string BotsBackupDirectoryUri { get; set; } = AppStatics.AppDefaultBotBackupDirectoryUri;
+
+ ///
+ /// The absolute path to the directory where the bot logs are stored.
+ ///
+ public string LogsDirectoryUri { get; set; } = AppStatics.AppDefaultLogDirectoryUri;
+
+ ///
+ /// The theme to be used.
+ ///
+ public ThemeType Theme { get; set; } = ThemeType.Auto;
+
+ ///
+ /// Determines whether the application should update itself.
+ ///
+ public bool AutomaticUpdates { get; set; } = true;
+
+ ///
+ /// Determines whether the application should be minimized to the system tray when closed.
+ ///
+ public bool MinimizeToTray { get; set; } = true;
+
+ ///
+ /// Determines the maximum size a log file can have, in Mb.
+ ///
+ public double LogMaxSizeMb { get; set; } = 0.5;
+
+ ///
+ /// Determines the size the application window should be set on startup.
+ ///
+ public WindowSize WindowSize { get; set; } = new(double.Parse(WindowConstants.DefaultWindowWidth), double.Parse(WindowConstants.DefaultWindowHeight));
+
+ ///
+ /// A collection of metadata about the bot instances.
+ ///
+ public ConcurrentDictionary BotEntries { get; init; } = new();
+}
\ No newline at end of file
diff --git a/EllieHub/Models/Config/ReadOnlyAppConfig.cs b/EllieHub/Models/Config/ReadOnlyAppConfig.cs
new file mode 100644
index 0000000..0c5b2d5
--- /dev/null
+++ b/EllieHub/Models/Config/ReadOnlyAppConfig.cs
@@ -0,0 +1,72 @@
+using EllieHub.Enums;
+
+namespace EllieHub.Models.Config;
+
+///
+/// Represents a read-only version of .
+///
+public sealed class ReadOnlyAppConfig
+{
+ private readonly AppConfig _appConfig;
+
+ ///
+ /// The absolute path to the directory where the bot instances are stored.
+ ///
+ public string BotsDirectoryUri
+ => _appConfig.BotsDirectoryUri;
+
+ ///
+ /// The absolute path to the directory where the bot instances are backed up.
+ ///
+ public string BotsBackupDirectoryUri
+ => _appConfig.BotsBackupDirectoryUri;
+
+ ///
+ /// The absolute path to the directory where the bot logs are stored.
+ ///
+ public string LogsDirectoryUri
+ => _appConfig.LogsDirectoryUri;
+
+ ///
+ /// The theme to be used.
+ ///
+ public ThemeType Theme
+ => _appConfig.Theme;
+
+ ///
+ /// Determines whether the application should update itself.
+ ///
+ public bool AutomaticUpdates
+ => _appConfig.AutomaticUpdates;
+
+ ///
+ /// Determines whether the application should be minimized to the system tray when closed.
+ ///
+ public bool MinimizeToTray
+ => _appConfig.MinimizeToTray;
+
+ ///
+ /// Determines the maximum size a log file can have, in Mb.
+ ///
+ public double LogMaxSizeMb
+ => _appConfig.LogMaxSizeMb;
+
+ ///
+ /// Determines the size the application window should be set on startup.
+ ///
+ public WindowSize WindowSize
+ => _appConfig.WindowSize;
+
+ ///
+ /// A collection of metadata about the bot instances.
+ ///
+ public IReadOnlyDictionary BotEntries
+ => _appConfig.BotEntries;
+
+ ///
+ /// Initializes a read-only version of .
+ ///
+ /// The application settings to read from.
+ public ReadOnlyAppConfig(AppConfig appConfig)
+ => _appConfig = appConfig;
+}
\ No newline at end of file
diff --git a/EllieHub/Models/EventArguments/AvatarChangedEventArgs.cs b/EllieHub/Models/EventArguments/AvatarChangedEventArgs.cs
new file mode 100644
index 0000000..59b8267
--- /dev/null
+++ b/EllieHub/Models/EventArguments/AvatarChangedEventArgs.cs
@@ -0,0 +1,37 @@
+using SkiaSharp;
+
+namespace EllieHub.Models.EventArguments;
+
+///
+/// Defines the event arguments for when the user sets a new avatar for a bot instance.
+///
+public sealed class AvatarChangedEventArgs : EventArgs
+{
+ ///
+ /// The Id of the bot.
+ ///
+ public Guid BotId { get; }
+
+ ///
+ /// The new avatar.
+ ///
+ public SKBitmap Avatar { get; }
+
+ ///
+ /// The absolute path to the avatar's file.
+ ///
+ public string AvatarUri { get; }
+
+ ///
+ /// Creates the event arguments for when the user sets a new avatar for a bot instance.
+ ///
+ /// The Id of the bot.
+ /// The new avatar.
+ /// The absolute path to the avatar's file.
+ public AvatarChangedEventArgs(Guid botId, SKBitmap avatar, string avatarUri)
+ {
+ BotId = botId;
+ Avatar = avatar;
+ AvatarUri = avatarUri;
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/Models/EventArguments/BotExitEventArgs.cs b/EllieHub/Models/EventArguments/BotExitEventArgs.cs
new file mode 100644
index 0000000..d2160eb
--- /dev/null
+++ b/EllieHub/Models/EventArguments/BotExitEventArgs.cs
@@ -0,0 +1,28 @@
+namespace EllieHub.Models.EventArguments;
+
+///
+/// Defines the event arguments when a bot process exits.
+///
+public sealed class BotExitEventArgs : EventArgs
+{
+ ///
+ /// The bot's Id.
+ ///
+ public Guid Id { get; }
+
+ ///
+ /// The exit code.
+ ///
+ public int ExitCode { get; }
+
+ ///
+ /// Creates the event arguments when a bot process exits.
+ ///
+ /// The bot's Id.
+ /// The exit code.
+ public BotExitEventArgs(Guid botId, int exitCode)
+ {
+ Id = botId;
+ ExitCode = exitCode;
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/Models/EventArguments/LogFlushEventArgs.cs b/EllieHub/Models/EventArguments/LogFlushEventArgs.cs
new file mode 100644
index 0000000..4cb8572
--- /dev/null
+++ b/EllieHub/Models/EventArguments/LogFlushEventArgs.cs
@@ -0,0 +1,35 @@
+namespace EllieHub.Models.EventArguments;
+
+///
+/// Defines the event arguments when a log is written to disk.
+///
+public sealed class LogFlushEventArgs : EventArgs
+{
+ ///
+ /// The absolute path to the recently created log file.
+ ///
+ public string FileUri { get; }
+
+ ///
+ /// The size of the log file, in bytes.
+ ///
+ public int Size { get; }
+
+ ///
+ /// The date the log file was created.
+ ///
+ public DateTimeOffset CreatedAt { get; }
+
+ ///
+ /// Creates the event arguments when a log is written to disk.
+ ///
+ /// The absolute path to the recently created log file.
+ /// The size of the log file, in bytes.
+ /// The date the log file was created.
+ public LogFlushEventArgs(string fileUri, int size, DateTimeOffset createdAt)
+ {
+ FileUri = fileUri;
+ Size = size;
+ CreatedAt = createdAt;
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/Models/EventArguments/ProcessStdWriteEventArgs.cs b/EllieHub/Models/EventArguments/ProcessStdWriteEventArgs.cs
new file mode 100644
index 0000000..d061195
--- /dev/null
+++ b/EllieHub/Models/EventArguments/ProcessStdWriteEventArgs.cs
@@ -0,0 +1,28 @@
+namespace EllieHub.Models.EventArguments;
+
+///
+/// Defines the event arguments when a bot process writes to stdout or stderr.
+///
+public sealed class ProcessStdWriteEventArgs : EventArgs
+{
+ ///
+ /// The Id of the bot.
+ ///
+ public Guid Id { get; }
+
+ ///
+ /// The value that was just written to std.
+ ///
+ public string Output { get; }
+
+ ///
+ /// Creates the event arguments when a bot process writes to stdout or stderr.
+ ///
+ /// The Id of the bot.
+ /// The value that was just written to std.
+ public ProcessStdWriteEventArgs(Guid botId, string output)
+ {
+ Id = botId;
+ Output = output;
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/Models/EventArguments/UriInputBarEventArgs.cs b/EllieHub/Models/EventArguments/UriInputBarEventArgs.cs
new file mode 100644
index 0000000..9faa5c9
--- /dev/null
+++ b/EllieHub/Models/EventArguments/UriInputBarEventArgs.cs
@@ -0,0 +1,30 @@
+using EllieHub.ViewModels.Controls;
+
+namespace EllieHub.Models.EventArguments;
+
+///
+/// Defines the event arguments for when a valid uri is set to a .
+///
+public sealed class UriInputBarEventArgs : EventArgs
+{
+ ///
+ /// The old valid uri.
+ ///
+ public string OldUri { get; }
+
+ ///
+ /// The new valid uri.
+ ///
+ public string NewUri { get; }
+
+ ///
+ /// Creates the event arguments for when a valid uri is set to a .
+ ///
+ /// The old valid uri.
+ /// The new valid uri.
+ public UriInputBarEventArgs(string oldUri, string newUri)
+ {
+ OldUri = oldUri;
+ NewUri = newUri;
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/Models/WindowSize.cs b/EllieHub/Models/WindowSize.cs
new file mode 100644
index 0000000..3f7b23b
--- /dev/null
+++ b/EllieHub/Models/WindowSize.cs
@@ -0,0 +1,8 @@
+namespace EllieHub.Models;
+
+///
+/// Represents the dimensions of the application's window.
+///
+/// The width of the application window.
+/// The height of the application window.
+public sealed record WindowSize(double Width, double Height);
\ No newline at end of file
diff --git a/EllieHub/Program.cs b/EllieHub/Program.cs
new file mode 100644
index 0000000..8a970c5
--- /dev/null
+++ b/EllieHub/Program.cs
@@ -0,0 +1,23 @@
+using Avalonia;
+using Avalonia.ReactiveUI;
+
+namespace EllieHub;
+
+internal sealed class Program
+{
+ // Initialization code. Don't use any Avalonia, third-party APIs or any
+ // SynchronizationContext-reliant code before AppMain is called: things aren't initialized
+ // yet and stuff might break.
+ [STAThread]
+ public static void Main(string[] args) => BuildAvaloniaApp()
+ .StartWithClassicDesktopLifetime(args);
+
+
+ // Avalonia configuration, don't remove; also used by visual designer.
+ public static AppBuilder BuildAvaloniaApp()
+ => AppBuilder.Configure()
+ .UsePlatformDetect()
+ .WithInterFont()
+ .LogToTrace()
+ .UseReactiveUI();
+}
\ No newline at end of file
diff --git a/EllieHub/Resources/Colors.axaml b/EllieHub/Resources/Colors.axaml
new file mode 100644
index 0000000..a378581
--- /dev/null
+++ b/EllieHub/Resources/Colors.axaml
@@ -0,0 +1,35 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #24A148
+ #D6D600
+
+
+
+ #FAF5F8
+ #FCFAFC
+ #FFFAFD
+ #FF0067
+ Blue
+
+
+ #252525
+ #202020
+ #181818
+ #D90058
+ #5090BB
+
+
+
\ No newline at end of file
diff --git a/EllieHub/Resources/Fonts.axaml b/EllieHub/Resources/Fonts.axaml
new file mode 100644
index 0000000..7b6d0a9
--- /dev/null
+++ b/EllieHub/Resources/Fonts.axaml
@@ -0,0 +1,19 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ avares://EllieHub/Assets/Fonts/NotoSans-Regular.ttf
+ avares://EllieHub/Assets/Fonts/NotoSans-Bold.ttf
+
\ No newline at end of file
diff --git a/EllieHub/Resources/Images.axaml b/EllieHub/Resources/Images.axaml
new file mode 100644
index 0000000..9e52a0d
--- /dev/null
+++ b/EllieHub/Resources/Images.axaml
@@ -0,0 +1,73 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ avares://EllieHub/Assets/ellie.png
+ avares://EllieHub/Assets/paypal.png
+ avares://EllieHub/Assets/patreon.png
+ avares://EllieHub/Assets/ko-fi.webp
+
+
+
+
+ avares://EllieHub/Assets/Light/check_for_updates.png
+ avares://EllieHub/Assets/Light/config.png
+ avares://EllieHub/Assets/Light/deps.png
+ avares://EllieHub/Assets/Light/docs.png
+ avares://EllieHub/Assets/Light/home.png
+ avares://EllieHub/Assets/Light/icon-commands.png
+ avares://EllieHub/Assets/Light/icon-embeds.png
+ avares://EllieHub/Assets/Light/icon-link.png
+ avares://EllieHub/Assets/Light/icon-suggest.png
+ avares://EllieHub/Assets/Light/icon-support.png
+ avares://EllieHub/Assets/Light/ellieupdatericon.ico
+ avares://EllieHub/Assets/Light/ellieupdatericon.ico
+ avares://EllieHub/Assets/Light/terminal.png
+
+
+ avares://EllieHub/Assets/Dark/check_for_updates.png
+ avares://EllieHub/Assets/Dark/config.png
+ avares://EllieHub/Assets/Dark/deps.png
+ avares://EllieHub/Assets/Dark/docs.png
+ avares://EllieHub/Assets/Dark/home.png
+ avares://EllieHub/Assets/Dark/icon-commands.png
+ avares://EllieHub/Assets/Dark/icon-embeds.png
+ avares://EllieHub/Assets/Dark/icon-link.png
+ avares://EllieHub/Assets/Dark/icon-suggest.png
+ avares://EllieHub/Assets/Dark/icon-support.png
+ avares://EllieHub/Assets/Dark/ellieupdatericon.ico
+ avares://EllieHub/Assets/Dark/ellieupdatericon.ico
+ avares://EllieHub/Assets/Dark/terminal.png
+
+
+
\ No newline at end of file
diff --git a/EllieHub/Services/Abstractions/FfmpregResolver.cs b/EllieHub/Services/Abstractions/FfmpregResolver.cs
new file mode 100644
index 0000000..4ffd60a
--- /dev/null
+++ b/EllieHub/Services/Abstractions/FfmpregResolver.cs
@@ -0,0 +1,71 @@
+namespace EllieHub.Services.Abstractions;
+
+///
+/// Base class for a service that checks, downloads, installs, and updates ffmpeg.
+///
+public abstract class FfmpegResolver : IFfmpegResolver
+{
+ private readonly string _programVerifier = (Environment.OSVersion.Platform is PlatformID.Win32NT) ? "where" : "which";
+
+ ///
+ /// The name of the Ffmpeg process.
+ ///
+ protected const string FfmpegProcessName = "ffmpeg";
+
+ ///
+ public string DependencyName { get; } = "FFMPEG";
+
+ ///
+ public abstract string FileName { get; }
+
+ ///
+ public virtual async ValueTask CanUpdateAsync(CancellationToken cToken = default)
+ {
+ // Check where ffmpeg is referenced.
+ using var whereProcess = Utilities.StartProcess(_programVerifier, FfmpegProcessName);
+ var installationPath = await whereProcess.StandardOutput.ReadToEndAsync(cToken);
+
+ // If ffmpeg is present but not managed by us, just report it is installed.
+ if (!string.IsNullOrWhiteSpace(installationPath) && !installationPath.Contains(AppStatics.AppDepsUri, StringComparison.Ordinal))
+ return false;
+
+ var currentVer = await GetCurrentVersionAsync(cToken);
+
+ // If ffmpeg or ffprobe are absent, a reinstall needs to be performed.
+ if (currentVer is null || !await Utilities.ProgramExistsAsync("ffprobe", cToken))
+ return null;
+
+ var latestVer = await GetLatestVersionAsync(cToken);
+
+ return !latestVer.Equals(currentVer, StringComparison.Ordinal);
+ }
+
+ ///
+ public virtual async ValueTask GetCurrentVersionAsync(CancellationToken cToken = default)
+ {
+ // If ffmpeg is not accessible from the shell...
+ if (!await Utilities.ProgramExistsAsync(FfmpegProcessName, cToken))
+ {
+ // And doesn't exist in the dependencies folder,
+ // report that ffmpeg is not installed.
+ if (!File.Exists(Path.Combine(AppStatics.AppDepsUri, FileName)))
+ return null;
+
+ // Else, add the dependencies directory to the PATH envar,
+ // then try again.
+ Utilities.AddPathToPATHEnvar(AppStatics.AppDepsUri);
+ return await GetCurrentVersionAsync(cToken);
+ }
+
+ using var ffmpeg = Utilities.StartProcess(FfmpegProcessName, "-version");
+ var match = AppStatics.FfmpegVersionRegex.Match(await ffmpeg.StandardOutput.ReadLineAsync(cToken) ?? string.Empty);
+
+ return match.Groups[1].Value;
+ }
+
+ ///
+ public abstract ValueTask GetLatestVersionAsync(CancellationToken cToken = default);
+
+ ///
+ public abstract ValueTask<(string? OldVersion, string? NewVersion)> InstallOrUpdateAsync(string dependenciesUri, CancellationToken cToken = default);
+}
\ No newline at end of file
diff --git a/EllieHub/Services/Abstractions/IAppConfigManager.cs b/EllieHub/Services/Abstractions/IAppConfigManager.cs
new file mode 100644
index 0000000..231fba9
--- /dev/null
+++ b/EllieHub/Services/Abstractions/IAppConfigManager.cs
@@ -0,0 +1,56 @@
+using EllieHub.Models;
+using EllieHub.Models.Config;
+
+namespace EllieHub.Services.Abstractions;
+
+///
+/// Represents a service that manages the application's settings.
+///
+public interface IAppConfigManager
+{
+ ///
+ /// The application settings.
+ ///
+ ReadOnlyAppConfig AppConfig { get; }
+
+ ///
+ /// Creates a bot entry.
+ ///
+ /// The cancellation token.
+ /// The bot entry that got created.
+ /// Occurs when the bot entry is not successfully created.
+ ValueTask CreateBotEntryAsync(CancellationToken cToken = default);
+
+ ///
+ /// Deletes a bot entry at the specified .
+ ///
+ /// The Id of the bot.
+ /// The cancellation token.
+ /// The bot entry that got deleted, otherwise.
+ ValueTask DeleteBotEntryAsync(Guid id, CancellationToken cToken = default);
+
+ ///
+ /// Moves a bot entry in the list.
+ ///
+ /// The bot being swapped.
+ /// The bot to swap with.
+ /// The cancellation token.
+ /// if the entry got moved, otherwise.
+ ValueTask SwapBotEntryAsync(Guid firstBotId, Guid secondBotId, CancellationToken cToken = default);
+
+ ///
+ /// Changes the bot entry with the specified .
+ ///
+ /// The Id of the bot.
+ /// The changes that should be performed on the entry.
+ /// The cancellation token.
+ /// if changes were made on the entry, otherwise.
+ ValueTask UpdateBotEntryAsync(Guid id, Func selector, CancellationToken cToken = default);
+
+ ///
+ /// Changes the application's settings file according to the .
+ ///
+ /// The action to be performed on the configuration file.
+ /// The cancellation token.
+ ValueTask UpdateConfigAsync(Action action, CancellationToken cToken = default);
+}
\ No newline at end of file
diff --git a/EllieHub/Services/Abstractions/IAppResolver.cs b/EllieHub/Services/Abstractions/IAppResolver.cs
new file mode 100644
index 0000000..cfcfe71
--- /dev/null
+++ b/EllieHub/Services/Abstractions/IAppResolver.cs
@@ -0,0 +1,28 @@
+namespace EllieHub.Services.Abstractions;
+
+///
+/// Represents a service that updates this application.
+///
+public interface IAppResolver : IDependencyResolver
+{
+ ///
+ /// The absolute path to the binary file of this application.
+ ///
+ string BinaryUri { get; }
+
+ ///
+ /// The suffix appended to the name of old files.
+ ///
+ string OldFileSuffix { get; }
+
+ ///
+ /// Removes the files from the old installation.
+ ///
+ /// if old files were removed, otherwise.
+ bool RemoveOldFiles();
+
+ ///
+ /// Starts the recently updated version of this application.
+ ///
+ void LaunchNewVersion();
+}
\ No newline at end of file
diff --git a/EllieHub/Services/Abstractions/IBotOrchestrator.cs b/EllieHub/Services/Abstractions/IBotOrchestrator.cs
new file mode 100644
index 0000000..689f422
--- /dev/null
+++ b/EllieHub/Services/Abstractions/IBotOrchestrator.cs
@@ -0,0 +1,51 @@
+using EllieHub.Models.EventArguments;
+
+namespace EllieHub.Services.Abstractions;
+
+///
+/// Represents an object that coordinates multiple running processes of Ellie.
+///
+public interface IBotOrchestrator
+{
+ ///
+ /// Raised when a bot process exits.
+ ///
+ event EventHandler? OnBotExit;
+
+ ///
+ /// Raised when a bot process prints data to stderr.
+ ///
+ event EventHandler? OnStderr;
+
+ ///
+ /// Raised when a bot process prints data to stdout.
+ ///
+ event EventHandler? OnStdout;
+
+ ///
+ /// Determines whether the bot with the specified .
+ ///
+ /// The bot's Id.
+ /// if the bot is running, otherwise.
+ bool IsBotRunning(Guid botId);
+
+ ///
+ /// Starts the bot with the specified .
+ ///
+ /// The bot's Id.
+ /// if the bot successfully started, otherwise.
+ bool Start(Guid botId);
+
+ ///
+ /// Stops the bot with the specified .
+ ///
+ /// The bot's Id.
+ /// if the bot successfully stopped, otherwise.
+ bool Stop(Guid botId);
+
+ ///
+ /// Stops all bot instances.
+ ///
+ /// if at least one bot instance was stopped, otherwise.
+ bool StopAll();
+}
\ No newline at end of file
diff --git a/EllieHub/Services/Abstractions/IBotResolver.cs b/EllieHub/Services/Abstractions/IBotResolver.cs
new file mode 100644
index 0000000..0ae96a7
--- /dev/null
+++ b/EllieHub/Services/Abstractions/IBotResolver.cs
@@ -0,0 +1,23 @@
+namespace EllieHub.Services.Abstractions;
+
+///
+/// Represents a service that checks, downloads, installs, and updates a bot instance.
+///
+public interface IBotResolver : IDependencyResolver
+{
+ ///
+ /// The name of the bot instance.
+ ///
+ string BotName { get; }
+
+ ///
+ /// The Id of the bot.
+ ///
+ Guid Id { get; }
+
+ ///
+ /// Creates a backup of the bot instance associated with this resolver.
+ ///
+ /// The absolute path to the backup file or if the backup failed.
+ ValueTask CreateBackupAsync();
+}
\ No newline at end of file
diff --git a/EllieHub/Services/Abstractions/IDependencyResolver.cs b/EllieHub/Services/Abstractions/IDependencyResolver.cs
new file mode 100644
index 0000000..8c002f3
--- /dev/null
+++ b/EllieHub/Services/Abstractions/IDependencyResolver.cs
@@ -0,0 +1,59 @@
+namespace EllieHub.Services.Abstractions;
+
+///
+/// Represents a service that checks, downloads, installs, and updates a dependency.
+///
+public interface IDependencyResolver
+{
+ ///
+ /// The name of this dependency.
+ ///
+ string DependencyName { get; }
+
+ ///
+ /// The name of the dependency binary file.
+ ///
+ string FileName { get; }
+
+ ///
+ /// Checks if the dependency can be updated.
+ ///
+ /// The cancellation token.
+ ///
+ /// if the dependency can be updated,
+ /// if the dependency is up-to-date,
+ /// if the dependency is not installed.
+ ///
+ ValueTask CanUpdateAsync(CancellationToken cToken = default);
+
+ ///
+ /// Gets the version of the dependency currently installed on this system.
+ ///
+ /// The cancellation token.
+ /// The version of the dependency on this system or if the dependency is not installed.
+ ValueTask GetCurrentVersionAsync(CancellationToken cToken = default);
+
+ ///
+ /// Gets the latest version of the dependency.
+ ///
+ /// The cancellation token.
+ /// The latest version of the dependency.
+ ///
+ /// Occurs when there is an issue with the redirection of GitHub's latest release link.
+ ///
+ ValueTask GetLatestVersionAsync(CancellationToken cToken = default);
+
+ ///
+ /// Installs or updates the dependency on this system.
+ ///
+ /// The absolute path to the directory where the dependency should be installed to.
+ /// The cancellation token.
+ ///
+ /// A tuple that may or may not contain the old and new versions of the dependency.
+ /// (, ): the dependency is being updated by another thread, so no operation was performed.
+ /// (, ): the dependency is already up-to-date, so no operation was performed.
+ /// (, ): the dependency got installed.
+ /// (, ): the dependency got updated.
+ ///
+ ValueTask<(string? OldVersion, string? NewVersion)> InstallOrUpdateAsync(string installationUri, CancellationToken cToken = default);
+}
\ No newline at end of file
diff --git a/EllieHub/Services/Abstractions/IFfmpegResolver.cs b/EllieHub/Services/Abstractions/IFfmpegResolver.cs
new file mode 100644
index 0000000..a627e9f
--- /dev/null
+++ b/EllieHub/Services/Abstractions/IFfmpegResolver.cs
@@ -0,0 +1,9 @@
+namespace EllieHub.Services.Abstractions;
+
+///
+/// Represents a service that checks, downloads, installs, and updates ffmpeg.
+///
+/// This interface exists mainly for DI registration.
+public interface IFfmpegResolver : IDependencyResolver
+{
+}
\ No newline at end of file
diff --git a/EllieHub/Services/Abstractions/ILogWriter.cs b/EllieHub/Services/Abstractions/ILogWriter.cs
new file mode 100644
index 0000000..bb2a2e3
--- /dev/null
+++ b/EllieHub/Services/Abstractions/ILogWriter.cs
@@ -0,0 +1,52 @@
+using EllieHub.Models.EventArguments;
+using System.Diagnostics.CodeAnalysis;
+
+namespace EllieHub.Services.Abstractions;
+
+///
+/// Represents a service that writes logs of bot instances to the disk.
+///
+public interface ILogWriter
+{
+ ///
+ /// Raised when a log file is created.
+ ///
+ event EventHandler? OnLogCreated;
+
+ ///
+ /// Writes the logs of all bots to a log file.
+ ///
+ ///
+ /// if the backing storage for the bot's logs should be removed from memory, otherwise.
+ ///
+ /// The cancellation token.
+ /// if at least one log file was created, otherwise.
+ Task FlushAllAsync(bool removeFromMemory = false, CancellationToken cToken = default);
+
+ ///
+ /// Writes the logs of the specified bot to a log file.
+ ///
+ /// The Id of the bot.
+ ///
+ /// if the backing storage for the bot's logs should be removed from memory, otherwise.
+ ///
+ /// The cancellation token.
+ /// if the log file was successfully created, otherwise.
+ Task FlushAsync(Guid botId, bool removeFromMemory = false, CancellationToken cToken = default);
+
+ ///
+ /// Safely adds a to the log of the bot with the specified .
+ ///
+ /// The Id of the bot.
+ /// The message to be appended to the log.
+ /// if the was successfully added to the log, otherwise.
+ bool TryAdd(Guid botId, string message);
+
+ ///
+ /// Safely gets the logs of the bot with the specified .
+ ///
+ /// The Id of the bot.
+ /// The log of the bot.
+ /// if the was successfully retrieved, otherwise.
+ bool TryRead(Guid botId, [MaybeNullWhen(false)] out string log);
+}
\ No newline at end of file
diff --git a/EllieHub/Services/Abstractions/IYtdlpResolver.cs b/EllieHub/Services/Abstractions/IYtdlpResolver.cs
new file mode 100644
index 0000000..a19834f
--- /dev/null
+++ b/EllieHub/Services/Abstractions/IYtdlpResolver.cs
@@ -0,0 +1,9 @@
+namespace EllieHub.Services.Abstractions;
+
+///
+/// Represents a service that checks, downloads, installs, and updates yt-dlp.
+///
+/// This interface exists mainly for DI registration.
+public interface IYtdlpResolver : IDependencyResolver
+{
+}
\ No newline at end of file
diff --git a/EllieHub/Services/AppConfigManager.cs b/EllieHub/Services/AppConfigManager.cs
new file mode 100644
index 0000000..1c8f66d
--- /dev/null
+++ b/EllieHub/Services/AppConfigManager.cs
@@ -0,0 +1,126 @@
+using EllieHub.Models;
+using EllieHub.Models.Config;
+using EllieHub.Services.Abstractions;
+using System.Text.Json;
+
+namespace EllieHub.Services;
+
+///
+/// Defines a service that manages the application's settings.
+///
+public sealed class AppConfigManager : IAppConfigManager
+{
+ private readonly JsonSerializerOptions _jsonSerializerOptions = new() { WriteIndented = true };
+ private readonly AppConfig _appConfig;
+
+ ///
+ public ReadOnlyAppConfig AppConfig { get; }
+
+ ///
+ /// Creates a service that manages the application's settings.
+ ///
+ public AppConfigManager(AppConfig appConfig, ReadOnlyAppConfig readOnlyAppConfig)
+ {
+ _appConfig = appConfig;
+ AppConfig = readOnlyAppConfig;
+
+ Directory.CreateDirectory(AppStatics.AppDefaultConfigDirectoryUri); // Create the directory where the app settings will be stored.
+ }
+
+ ///
+ public async ValueTask CreateBotEntryAsync(CancellationToken cToken = default)
+ {
+ var newId = CreateNewId();
+ var newPosition = (_appConfig.BotEntries.Count is 0) ? 0 : _appConfig.BotEntries.Values.Max(x => x.Position) + 1;
+ var newBotName = "NewBot_" + newPosition;
+ var newEntry = new BotInstanceInfo(newBotName, Path.Combine(_appConfig.BotsDirectoryUri, newBotName), newPosition);
+
+ if (!_appConfig.BotEntries.TryAdd(newId, newEntry))
+ throw new InvalidOperationException($"Could not create a new bot entry with Id {newId}.");
+
+ await SaveAsync(cToken);
+
+ return new(newId, newEntry);
+ }
+
+ ///
+ public async ValueTask DeleteBotEntryAsync(Guid id, CancellationToken cToken = default)
+ {
+ if (!_appConfig.BotEntries.TryRemove(id, out var removedEntry))
+ return null;
+
+ Utilities.TryDeleteDirectory(removedEntry.InstanceDirectoryUri);
+
+ await SaveAsync(cToken);
+
+ return new(id, removedEntry);
+ }
+
+ ///
+ public async ValueTask SwapBotEntryAsync(Guid firstBotId, Guid secondBotId, CancellationToken cToken = default)
+ {
+ if (firstBotId == secondBotId
+ || !_appConfig.BotEntries.TryGetValue(firstBotId, out var firstBotEntry)
+ || !_appConfig.BotEntries.TryGetValue(secondBotId, out var secondBotEntry))
+ return false;
+
+ var tempFirstPosition = firstBotEntry.Position;
+
+ _appConfig.BotEntries[firstBotId] = _appConfig.BotEntries[firstBotId] with { Position = secondBotEntry.Position };
+ _appConfig.BotEntries[secondBotId] = _appConfig.BotEntries[secondBotId] with { Position = tempFirstPosition };
+
+ await SaveAsync(cToken);
+
+ return true;
+ }
+
+ ///
+ public async ValueTask UpdateBotEntryAsync(Guid id, Func selector, CancellationToken cToken = default)
+ {
+ if (!_appConfig.BotEntries.TryRemove(id, out var entry))
+ return false;
+
+ var updatedEntry = selector(entry);
+
+ _appConfig.BotEntries.TryAdd(id, updatedEntry);
+
+ await SaveAsync(cToken);
+
+ return true;
+ }
+
+ ///
+ public ValueTask UpdateConfigAsync(Action action, CancellationToken cToken = default)
+ {
+ action(_appConfig);
+ return SaveAsync(cToken);
+ }
+
+ ///
+ /// Creates a new Id that is known to be unique in the configuration file.
+ ///
+ /// An unique Id.
+ private Guid CreateNewId()
+ {
+ var id = Guid.NewGuid();
+
+ while (_appConfig.BotEntries.ContainsKey(id))
+ id = Guid.NewGuid();
+
+ return id;
+ }
+
+ ///
+ /// Saves the bot entries to a configuration file.
+ ///
+ /// The cancellation token.
+ private async ValueTask SaveAsync(CancellationToken cToken = default)
+ {
+ // Create the directory where the config file will be stored, if it doesn't exist.
+ Directory.CreateDirectory(AppStatics.AppDefaultConfigDirectoryUri);
+
+ // Create the configuration file.
+ var json = JsonSerializer.Serialize(_appConfig, _jsonSerializerOptions);
+ await File.WriteAllTextAsync(AppStatics.AppConfigUri, json, cToken);
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/Services/AppResolver.cs b/EllieHub/Services/AppResolver.cs
new file mode 100644
index 0000000..7ad4454
--- /dev/null
+++ b/EllieHub/Services/AppResolver.cs
@@ -0,0 +1,185 @@
+using EllieHub.Services.Abstractions;
+using System.IO.Compression;
+using System.Reflection;
+using System.Runtime.InteropServices;
+
+namespace EllieHub.Services;
+
+///
+/// Defines a service that updates this application.
+///
+public sealed class AppResolver : IAppResolver
+{
+ private static readonly string _tempDirectory = Path.GetTempPath();
+ private static readonly string _downloadedFileName = GetDownloadFileName();
+ private static readonly string? _currentUpdaterVersion = Assembly.GetEntryAssembly()?.GetName().Version?.ToString();
+ private readonly IHttpClientFactory _httpClientFactory;
+
+ ///
+ public string DependencyName { get; } = "EllieHub";
+
+ ///
+ public string OldFileSuffix { get; } = "_old";
+
+ ///
+ public string FileName { get; }
+
+ ///
+ public string BinaryUri { get; }
+
+ ///
+ /// Creates a service that updates this application.
+ ///
+ /// The Http client factory.
+ public AppResolver(IHttpClientFactory httpClientFactory)
+ {
+ _httpClientFactory = httpClientFactory;
+ FileName = (OperatingSystem.IsWindows()) ? "EllieHub.exe" : "EllieHub";
+ BinaryUri = Path.Combine(AppContext.BaseDirectory, FileName);
+ }
+
+ ///
+ public ValueTask GetCurrentVersionAsync(CancellationToken cToken = default)
+ => ValueTask.FromResult(_currentUpdaterVersion);
+
+ ///
+ public void LaunchNewVersion()
+ => Utilities.StartProcess(BinaryUri);
+
+ ///
+ /// if the updater can be updated,
+ /// if the updater is up-to-date,
+ /// if the updater cannot update itself.
+ ///
+ ///
+ public async ValueTask CanUpdateAsync(CancellationToken cToken = default)
+ {
+ if (!Utilities.CanWriteTo(AppContext.BaseDirectory))
+ return null;
+
+ var currentVersion = await GetCurrentVersionAsync(cToken);
+
+ if (currentVersion is null)
+ return null;
+
+ var latestVersion = await GetLatestVersionAsync(cToken);
+
+ if (latestVersion.Equals(currentVersion, StringComparison.Ordinal))
+ return false;
+
+ var http = _httpClientFactory.CreateClient();
+
+ return await http.IsUrlValidAsync($"https://toastielab.dev/ToastieSharp/EllieHub/releases/download/{latestVersion}/{_downloadedFileName}", cToken);
+ }
+
+ ///
+ public bool RemoveOldFiles()
+ {
+ var result = false;
+
+ foreach (var file in Directory.GetFiles(AppContext.BaseDirectory).Where(x => x.EndsWith(OldFileSuffix)))
+ result |= Utilities.TryDeleteFile(file);
+
+ return result;
+ }
+
+ ///
+ public async ValueTask GetLatestVersionAsync(CancellationToken cToken = default)
+ {
+ var http = _httpClientFactory.CreateClient(AppConstants.NoRedirectClient);
+
+ var response = await http.GetAsync("https://toastielab.dev/ToastieSharp/EllieHub/releases/latest", cToken);
+
+ var lastSlashIndex = response.Headers.Location?.OriginalString.LastIndexOf('/')
+ ?? throw new InvalidOperationException("Failed to get the latest EllieBotUpdater version.");
+
+ return response.Headers.Location.OriginalString[(lastSlashIndex + 1)..];
+ }
+
+ ///
+ public async ValueTask<(string? OldVersion, string? NewVersion)> InstallOrUpdateAsync(string installationUri, CancellationToken cToken = default)
+ {
+ var currentVersion = await GetCurrentVersionAsync(cToken);
+ var latestVersion = await GetLatestVersionAsync(cToken);
+
+ if (latestVersion.Equals(currentVersion, StringComparison.Ordinal))
+ return (currentVersion, null);
+
+ var http = _httpClientFactory.CreateClient();
+ var appTempLocation = Path.Combine(_tempDirectory, _downloadedFileName[..(_downloadedFileName.LastIndexOf('.'))]);
+ var zipTempLocation = Path.Combine(_tempDirectory, _downloadedFileName);
+
+ try
+ {
+ using var downloadStream = await http.GetStreamAsync($"https://toastielab.dev/ToastieSharp/EllieHub/releases/download/{latestVersion}/{_downloadedFileName}", cToken);
+
+ // Save the zip file
+ using (var fileStream = new FileStream(zipTempLocation, FileMode.Create))
+ await downloadStream.CopyToAsync(fileStream, cToken);
+
+ // Extract the zip file
+ await Task.Run(() => ZipFile.ExtractToDirectory(zipTempLocation, _tempDirectory), cToken);
+
+ // Move the new binary and its dependencies
+ var newFilesUris = Directory.EnumerateFiles(appTempLocation);
+
+ foreach (var newFileUri in newFilesUris)
+ {
+ var destinationUri = Path.Combine(AppContext.BaseDirectory, newFileUri[(newFileUri.LastIndexOf(Path.DirectorySeparatorChar) + 1)..]);
+
+ // Rename the original file from "file" to "file_old".
+ if (File.Exists(destinationUri))
+ File.Move(destinationUri, destinationUri + OldFileSuffix);
+
+ // Move the new file to the application's directory.
+ if (Environment.OSVersion.Platform is not PlatformID.Unix)
+ File.Move(newFileUri, destinationUri, true);
+ else
+ {
+ // Circumvent this issue on Unix systems: https://github.com/dotnet/runtime/issues/31149
+ using var moveProcess = Utilities.StartProcess("mv", $"\"{newFileUri}\" \"{destinationUri}\"");
+ await moveProcess.WaitForExitAsync(cToken);
+ }
+ }
+
+ // Mark the new binary file as executable.
+ if (Environment.OSVersion.Platform is PlatformID.Unix)
+ {
+ using var chmod = Utilities.StartProcess("chmod", $"+x \"{BinaryUri}\"");
+ await chmod.WaitForExitAsync(cToken);
+ }
+
+ return (currentVersion, latestVersion);
+ }
+ finally
+ {
+ // Cleanup
+ Utilities.TryDeleteFile(zipTempLocation);
+ Utilities.TryDeleteDirectory(appTempLocation);
+ }
+ }
+
+ ///
+ /// Gets the name of the file to be downloaded.
+ ///
+ /// The name of the file to be downloaded.
+ /// Occurs when this method is used in an unsupported system.
+ private static string GetDownloadFileName()
+ {
+ return RuntimeInformation.OSArchitecture switch
+ {
+ // Windows
+ Architecture.X64 when OperatingSystem.IsWindows() => "EllieHub_win-x64.zip",
+ Architecture.Arm64 when OperatingSystem.IsWindows() => "EllieHub_win-arm64.zip",
+
+ // Linux
+ Architecture.X64 when OperatingSystem.IsLinux() => "EllieHub_linux-x64.zip",
+ Architecture.Arm64 when OperatingSystem.IsLinux() => "EllieHub_linux-arm64.zip",
+
+ // MacOS
+ Architecture.X64 when OperatingSystem.IsMacOS() => "EllieHub_osx-x64.zip",
+ Architecture.Arm64 when OperatingSystem.IsMacOS() => "EllieHub_osx-arm64.zip",
+ _ => throw new NotSupportedException($"Architecture of type {RuntimeInformation.OSArchitecture} is not supported by EllieHub on this OS.")
+ };
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/Services/EllieOrchestrator.cs b/EllieHub/Services/EllieOrchestrator.cs
new file mode 100644
index 0000000..2b05bd3
--- /dev/null
+++ b/EllieHub/Services/EllieOrchestrator.cs
@@ -0,0 +1,139 @@
+using EllieHub.Models.Config;
+using EllieHub.Models.EventArguments;
+using EllieHub.Services.Abstractions;
+using System.Diagnostics;
+
+namespace EllieHub.Services;
+
+///
+/// Defines an object that coordinates multiple running processes of EllieBot.
+///
+public sealed class EllieOrchestrator : IBotOrchestrator
+{
+ private readonly Dictionary _runningBots = new();
+ private readonly ReadOnlyAppConfig _appConfig;
+ private readonly string _fileName = (OperatingSystem.IsWindows()) ? "NadekoBot.exe" : "NadekoBot";
+
+ ///
+ public event EventHandler? OnBotExit;
+
+ ///
+ public event EventHandler? OnStderr;
+
+ ///
+ public event EventHandler? OnStdout;
+
+ ///
+ /// Creates an object that coordinates multiple running processes of EllieBot.
+ ///
+ /// The application settings.
+ public EllieOrchestrator(ReadOnlyAppConfig appConfig)
+ => _appConfig = appConfig;
+
+
+ ///
+ public bool IsBotRunning(Guid botId)
+ => _runningBots.ContainsKey(botId);
+
+
+ ///
+ public bool Start(Guid botId)
+ {
+ if (_runningBots.ContainsKey(botId)
+ || !_appConfig.BotEntries.TryGetValue(botId, out var botEntry)
+ || !File.Exists(Path.Combine(botEntry.InstanceDirectoryUri, _fileName)))
+ return false;
+
+ var botProcess = Process.Start(new ProcessStartInfo()
+ {
+ FileName = Path.Combine(botEntry.InstanceDirectoryUri, _fileName),
+ WorkingDirectory = botEntry.InstanceDirectoryUri,
+ UseShellExecute = false,
+ CreateNoWindow = true,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ });
+
+ if (botProcess is null)
+ return false;
+
+ botProcess.EnableRaisingEvents = true;
+ botProcess.OutputDataReceived += EmitStdout;
+ botProcess.ErrorDataReceived += EmitStderr;
+ botProcess.Exited += OnExit;
+ botProcess.BeginOutputReadLine();
+ botProcess.BeginErrorReadLine();
+
+ return _runningBots.TryAdd(botId, botProcess);
+ }
+
+ ///
+ public bool Stop(Guid botId)
+ {
+ if (!_runningBots.TryGetValue(botId, out var botProcess))
+ return false;
+
+ botProcess.Kill(true);
+ return true;
+ }
+
+ ///
+ public bool StopAll()
+ {
+ var amount = _runningBots.Count;
+
+ foreach (var process in _runningBots.Values)
+ try { process.Kill(true); } catch { }
+
+ return amount is not 0;
+ }
+
+ ///
+ /// Finalizes a process when it stops running.
+ ///
+ /// The .
+ /// The event arguments.
+ /// Occurs when is not of type .
+ private void OnExit(object? sender, EventArgs eventArgs)
+ {
+ var (id, process) = _runningBots.First(x => x.Value.Equals(sender));
+ OnBotExit?.Invoke(this, new(id, process.ExitCode));
+
+ _runningBots.Remove(id);
+ process.CancelOutputRead();
+ process.CancelErrorRead();
+ process.Dispose();
+ }
+
+ ///
+ /// Raises with its appropriate event arguments.
+ ///
+ /// The .
+ /// The event arguments.
+ private void EmitStdout(object sender, DataReceivedEventArgs eventArgs)
+ {
+ if (string.IsNullOrWhiteSpace(eventArgs.Data))
+ return;
+
+ var (id, _) = _runningBots.First(x => x.Value.Equals(sender));
+ var newEventArgs = new ProcessStdWriteEventArgs(id, eventArgs.Data);
+
+ OnStdout?.Invoke(this, newEventArgs);
+ }
+
+ ///
+ /// Raises with its appropriate event arguments.
+ ///
+ /// The .
+ /// The event arguments.
+ private void EmitStderr(object sender, DataReceivedEventArgs eventArgs)
+ {
+ if (string.IsNullOrWhiteSpace(eventArgs.Data))
+ return;
+
+ var (id, _) = _runningBots.First(x => x.Value.Equals(sender));
+ var newEventArgs = new ProcessStdWriteEventArgs(id, eventArgs.Data);
+
+ OnStderr?.Invoke(this, newEventArgs);
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/Services/EllieResolver.cs b/EllieHub/Services/EllieResolver.cs
new file mode 100644
index 0000000..6e2f6cf
--- /dev/null
+++ b/EllieHub/Services/EllieResolver.cs
@@ -0,0 +1,274 @@
+using EllieHub.Services.Abstractions;
+using System.Formats.Tar;
+using System.IO.Compression;
+using System.Reflection;
+using System.Runtime.InteropServices;
+using System.Text.RegularExpressions;
+
+namespace EllieHub.Services;
+
+///
+/// Service that checks, downloads, installs, and updates a NadekoBot instance.
+///
+/// Source: https://gitlab.com/Kwoth/nadekobot/-/releases/permalink/latest
+public sealed partial class EllieResolver : IBotResolver
+{
+ private static readonly HashSet _updateIdOngoing = new();
+ private static readonly string _tempDirectory = Path.GetTempPath();
+ private static readonly Regex _unzipedDirRegex = GenerateUnzipedDirRegex();
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly IAppConfigManager _appConfigManager;
+
+ ///
+ public string DependencyName { get; } = "NadekoBot";
+
+ ///
+ public string FileName { get; } = (OperatingSystem.IsWindows()) ? "NadekoBot.exe" : "NadekoBot";
+
+ ///
+ public Guid Id { get; }
+
+ ///
+ public string BotName { get; }
+
+ ///
+ /// Creates a service that checks, downloads, installs, and updates a NadekoBot instance.
+ ///
+ /// The HTTP client factory.
+ /// The application's settings.
+ /// The Id of the bot.
+ public EllieResolver(IHttpClientFactory httpClientFactory, IAppConfigManager appConfigManager, Guid botId)
+ {
+ _httpClientFactory = httpClientFactory;
+ _appConfigManager = appConfigManager;
+ Id = botId;
+ BotName = _appConfigManager.AppConfig.BotEntries[Id].Name;
+ }
+
+ ///
+ public async ValueTask CanUpdateAsync(CancellationToken cToken = default)
+ {
+ var currentVersion = await GetCurrentVersionAsync(cToken);
+
+ if (currentVersion is null)
+ return null;
+
+ var latestVersion = await GetLatestVersionAsync(cToken);
+
+ if (latestVersion.Equals(currentVersion, StringComparison.Ordinal))
+ return false;
+
+ var http = _httpClientFactory.CreateClient();
+
+ return await http.IsUrlValidAsync(
+ $"https://gitlab.com/api/v4/projects/9321079/packages/generic/NadekoBot-build/{latestVersion}/{GetDownloadFileName(latestVersion)}",
+ cToken
+ );
+ }
+
+ ///
+ public async ValueTask CreateBackupAsync()
+ {
+ var botInstance = _appConfigManager.AppConfig.BotEntries[Id];
+
+ if (!Directory.Exists(botInstance.InstanceDirectoryUri))
+ return null;
+
+ Directory.CreateDirectory(_appConfigManager.AppConfig.BotsBackupDirectoryUri);
+
+ var now = DateTimeOffset.Now;
+ var date = new DateOnly(now.Year, now.Month, now.Day).ToShortDateString().Replace('/', '-');
+ var backupZipName = $"{botInstance.Name}_{date}-{now.ToUnixTimeMilliseconds()}.zip";
+ var destinationUri = Path.Combine(_appConfigManager.AppConfig.BotsBackupDirectoryUri, backupZipName);
+
+ // ZipFile does not provide asynchronous implementations, so we have to schedule its
+ // execution to be run in the thread-pool due to how long it takes to finish execution.
+ await Task.Run(() => ZipFile.CreateFromDirectory(botInstance.InstanceDirectoryUri, destinationUri, CompressionLevel.SmallestSize, true));
+
+ return destinationUri;
+ }
+
+ ///
+ public async ValueTask GetCurrentVersionAsync(CancellationToken cToken = default)
+ {
+ var botEntry = _appConfigManager.AppConfig.BotEntries[Id];
+
+ if (!string.IsNullOrWhiteSpace(botEntry.Version))
+ return botEntry.Version;
+
+ var assemblyUri = Path.Combine(botEntry.InstanceDirectoryUri, "NadekoBot.dll");
+
+ if (!File.Exists(assemblyUri))
+ return null;
+ var nadekoAssembly = Assembly.LoadFile(assemblyUri);
+ var version = nadekoAssembly.GetName().Version
+ ?? throw new InvalidOperationException($"Could not find version of the assembly at {assemblyUri}.");
+
+ var currentVersion = $"{version.Major}.{version.Minor}.{version.Build}";
+
+ await _appConfigManager.UpdateBotEntryAsync(Id, x => x with { Version = currentVersion }, cToken);
+
+ return currentVersion;
+ }
+
+ ///
+ public async ValueTask GetLatestVersionAsync(CancellationToken cToken = default)
+ {
+ var http = _httpClientFactory.CreateClient(AppConstants.NoRedirectClient);
+
+ var response = await http.GetAsync("https://gitlab.com/Kwoth/nadekobot/-/releases/permalink/latest", cToken);
+
+ var lastSlashIndex = response.Headers.Location?.OriginalString.LastIndexOf('/')
+ ?? throw new InvalidOperationException("Failed to get the latest NadekoBot version.");
+
+ return response.Headers.Location.OriginalString[(lastSlashIndex + 1)..];
+ }
+
+ ///
+ public async ValueTask<(string? OldVersion, string? NewVersion)> InstallOrUpdateAsync(string installationUri, CancellationToken cToken = default)
+ {
+ if (_updateIdOngoing.Contains(Id))
+ return (null, null);
+
+ _updateIdOngoing.Add(Id);
+
+ var currentVersion = await GetCurrentVersionAsync(cToken);
+ var latestVersion = await GetLatestVersionAsync(cToken);
+
+ // Update
+ if (latestVersion == currentVersion)
+ {
+ _updateIdOngoing.Remove(Id);
+ return (currentVersion, null);
+ }
+
+ var backupFileUri = await CreateBackupAsync();
+
+ if (currentVersion is not null)
+ Directory.Delete(installationUri, true);
+
+ // Install
+ Directory.CreateDirectory(_appConfigManager.AppConfig.BotsDirectoryUri);
+
+ var http = _httpClientFactory.CreateClient();
+ var downloadFileName = GetDownloadFileName(latestVersion);
+ var botTempLocation = Path.Combine(_tempDirectory, "nadekobot-" + _unzipedDirRegex.Match(downloadFileName).Groups[1].Value);
+ var zipTempLocation = Path.Combine(_tempDirectory, downloadFileName);
+
+ try
+ {
+ using var downloadStream = await http.GetStreamAsync(
+ $"https://gitlab.com/api/v4/projects/9321079/packages/generic/NadekoBot-build/{latestVersion}/{downloadFileName}",
+ cToken
+ );
+
+ // Move the bot root directory while renaming it
+ if (Environment.OSVersion.Platform is not PlatformID.Unix)
+ {
+ // Save the zip file
+ using (var fileStream = new FileStream(zipTempLocation, FileMode.Create))
+ await downloadStream.CopyToAsync(fileStream, cToken);
+
+ // Extract the zip file
+ await Task.Run(() => ZipFile.ExtractToDirectory(zipTempLocation, _tempDirectory), cToken);
+
+ // Move the bot root directory while renaming it
+ Directory.Move(botTempLocation, installationUri);
+ }
+ else
+ {
+ // Extract the tar ball
+ await TarFile.ExtractToDirectoryAsync(downloadStream, _tempDirectory, true, cToken);
+
+ // Move the bot root directory with "mv" to circumvent this issue on Unix systems: https://github.com/dotnet/runtime/issues/31149
+ using var moveProcess = Utilities.StartProcess("mv", $"\"{botTempLocation}\" \"{installationUri}\"");
+ await moveProcess.WaitForExitAsync(cToken);
+
+ // Set executable permission
+ using var chmod = Utilities.StartProcess("chmod", $"+x \"{Path.Combine(installationUri, FileName)}\"");
+ await chmod.WaitForExitAsync(cToken);
+ }
+
+ // Reapply bot settings
+ if (File.Exists(backupFileUri))
+ {
+ using var zipFile = ZipFile.OpenRead(backupFileUri);
+ var zippedFiles = zipFile.Entries
+ .Where(x =>
+ x.Name is "creds.yml" or "creds_example.yml"
+ || (!string.IsNullOrWhiteSpace(x.Name) && x.FullName.Contains("data/"))
+ );
+
+ foreach (var zippedFile in zippedFiles)
+ {
+ var fileDestinationPath = zippedFile.FullName.Split('/')
+ .Prepend(Directory.GetParent(installationUri)?.FullName ?? string.Empty)
+ .ToArray();
+
+ await RestoreFileAsync(zippedFile, Path.Combine(fileDestinationPath), cToken);
+ }
+ }
+
+ // Update settings
+ await _appConfigManager.UpdateBotEntryAsync(Id, x => x with { Version = latestVersion }, cToken);
+
+ // Create creds.yml
+ var credsUri = Path.Combine(installationUri, "creds.yml");
+
+ if (!File.Exists(credsUri))
+ File.Copy(Path.Combine(installationUri, "creds_example.yml"), credsUri);
+
+ return (currentVersion, latestVersion);
+ }
+ finally
+ {
+ _updateIdOngoing.Remove(Id);
+
+ // Cleanup
+ Utilities.TryDeleteFile(zipTempLocation);
+ Utilities.TryDeleteDirectory(botTempLocation);
+ }
+ }
+
+ ///
+ /// Extracts the specified to the .
+ ///
+ /// The file to be extracted.
+ /// The final location of the extracted file.
+ /// The cancellation token.
+ private async static ValueTask RestoreFileAsync(ZipArchiveEntry zippedFile, string destinationPath, CancellationToken cToken = default)
+ {
+ using var zipStream = zippedFile.Open();
+ using var fileStream = new FileStream(destinationPath, FileMode.Create);
+
+ await zipStream.CopyToAsync(fileStream, cToken);
+ }
+
+ ///
+ /// Gets the name of the file to be downloaded.
+ ///
+ /// The version of NadekoBot.
+ /// The name of the file to download.
+ /// Occurs when this method is executed in an unsupported platform.
+ private static string GetDownloadFileName(string version)
+ {
+ return version + RuntimeInformation.OSArchitecture switch
+ {
+ // Windows
+ Architecture.X64 when OperatingSystem.IsWindows() => "-windows-x64-build.zip",
+ Architecture.Arm64 when OperatingSystem.IsWindows() => "-windows-arm64-build.zip",
+
+ // Linux
+ Architecture.X64 when OperatingSystem.IsLinux() => "-linux-x64-build.tar",
+ Architecture.Arm64 when OperatingSystem.IsLinux() => "-linux-arm64-build.tar",
+
+ // MacOS
+ Architecture.X64 when OperatingSystem.IsMacOS() => "-osx-x64-build.tar",
+ Architecture.Arm64 when OperatingSystem.IsMacOS() => "-osx-arm64-build.tar",
+ _ => throw new NotSupportedException($"Architecture of type {RuntimeInformation.OSArchitecture} is not supported by NadekoBot on this OS.")
+ };
+ }
+
+ [GeneratedRegex(@"^(?:\S+\-)(\S+\-\S+)\-", RegexOptions.Compiled)]
+ private static partial Regex GenerateUnzipedDirRegex();
+}
\ No newline at end of file
diff --git a/EllieHub/Services/FfmpegLinuxResolver.cs b/EllieHub/Services/FfmpegLinuxResolver.cs
new file mode 100644
index 0000000..b81a174
--- /dev/null
+++ b/EllieHub/Services/FfmpegLinuxResolver.cs
@@ -0,0 +1,110 @@
+using EllieHub.Services.Abstractions;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+using System.Text.RegularExpressions;
+
+namespace EllieHub.Services;
+
+///
+/// Service that checks, downloads, installs, and updates ffmpeg on Linux.
+///
+/// Source: https://johnvansickle.com/ffmpeg
+[SupportedOSPlatform("linux")]
+public sealed partial class FfmpegLinuxResolver : FfmpegResolver
+{
+ private readonly Regex _ffmpegLatestVersionRegex = FfmpegLatestVersionRegexGenerator();
+ private readonly string _tempDirectory = Path.GetTempPath();
+ private bool _isUpdating = false;
+ private readonly IHttpClientFactory _httpClientFactory;
+
+ ///
+ public override string FileName { get; } = "ffmpeg";
+
+ ///
+ /// Creates a service that checks, downloads, installs, and updates ffmpeg on Linux.
+ ///
+ /// The HTTP client factory.
+ public FfmpegLinuxResolver(IHttpClientFactory httpClientFactory)
+ => _httpClientFactory = httpClientFactory;
+
+ ///
+ public override ValueTask CanUpdateAsync(CancellationToken cToken = default)
+ {
+ return (RuntimeInformation.OSArchitecture is Architecture.X64 or Architecture.Arm64)
+ ? base.CanUpdateAsync(cToken)
+ : ValueTask.FromResult(false);
+ }
+
+ ///
+ public override async ValueTask GetLatestVersionAsync(CancellationToken cToken = default)
+ {
+ var http = _httpClientFactory.CreateClient();
+ var pageContent = await http.GetStringAsync("https://johnvansickle.com/ffmpeg", cToken);
+ var match = _ffmpegLatestVersionRegex.Match(pageContent);
+
+ return (match.Success)
+ ? match.Groups[1].Value
+ : throw new InvalidOperationException("Regex did not match the web page content.");
+ }
+
+ ///
+ public override async ValueTask<(string? OldVersion, string? NewVersion)> InstallOrUpdateAsync(string dependenciesUri, CancellationToken cToken = default)
+ {
+ if (_isUpdating)
+ return (null, null);
+
+ _isUpdating = true;
+ var currentVersion = await GetCurrentVersionAsync(cToken);
+ var newVersion = await GetLatestVersionAsync(cToken);
+
+ // Update
+ if (currentVersion is not null)
+ {
+ // If the versions are the same, exit.
+ if (currentVersion == newVersion)
+ return (currentVersion, null);
+
+ Utilities.TryDeleteFile(Path.Combine(dependenciesUri, FileName));
+ Utilities.TryDeleteFile(Path.Combine(dependenciesUri, "ffprobe"));
+ }
+
+ // Install
+ Directory.CreateDirectory(dependenciesUri);
+
+ var architecture = (RuntimeInformation.OSArchitecture is Architecture.X64) ? "amd" : "arm";
+ var tarFileName = $"ffmpeg-release-{architecture}64-static.tar.xz";
+ var http = _httpClientFactory.CreateClient();
+ using var downloadStream = await http.GetStreamAsync($"https://johnvansickle.com/ffmpeg/releases/{tarFileName}", cToken);
+
+ // Save tar file to the temporary directory.
+ var tarFilePath = Path.Combine(_tempDirectory, tarFileName);
+ var tarExtractDir = Path.Combine(_tempDirectory, $"ffmpeg-{newVersion}-{architecture}64-static");
+ using (var fileStream = new FileStream(tarFilePath, FileMode.Create))
+ await downloadStream.CopyToAsync(fileStream, cToken);
+
+ // Extract the tar file.
+ using var extractProcess = Utilities.StartProcess("tar", $"xf \"{tarFilePath}\" --directory=\"{_tempDirectory}\"");
+ await extractProcess.WaitForExitAsync(cToken);
+
+ // Move ffmpeg to the dependencies directory.
+ File.Move(Path.Combine(tarExtractDir, FileName), Path.Combine(dependenciesUri, FileName), true);
+ File.Move(Path.Combine(tarExtractDir, "ffprobe"), Path.Combine(dependenciesUri, "ffprobe"), true);
+
+ // Mark the files as executable.
+ using var chmod = Utilities.StartProcess("chmod", $"+x \"{Path.Combine(dependenciesUri, FileName)}\" \"{Path.Combine(dependenciesUri, "ffprobe")}\"");
+ await chmod.WaitForExitAsync(cToken);
+
+ // Cleanup
+ File.Delete(tarFilePath);
+ Directory.Delete(tarExtractDir, true);
+
+ // Update environment variable
+ Utilities.AddPathToPATHEnvar(dependenciesUri);
+
+ _isUpdating = false;
+ return (currentVersion, newVersion);
+ }
+
+ [GeneratedRegex(@"release:\s?([\d\.]+)", RegexOptions.Compiled)]
+ private static partial Regex FfmpegLatestVersionRegexGenerator();
+}
\ No newline at end of file
diff --git a/EllieHub/Services/FfmpegMacResolver.cs b/EllieHub/Services/FfmpegMacResolver.cs
new file mode 100644
index 0000000..3250fba
--- /dev/null
+++ b/EllieHub/Services/FfmpegMacResolver.cs
@@ -0,0 +1,116 @@
+using EllieHub.Models.Api;
+using EllieHub.Services.Abstractions;
+using System.IO.Compression;
+using System.Runtime.Versioning;
+
+namespace EllieHub.Services;
+
+///
+/// Service that checks, downloads, installs, and updates ffmpeg on MacOS.
+///
+/// Source: https://evermeet.cx/ffmpeg
+[SupportedOSPlatform("osx")]
+public sealed class FfmpegMacResolver : FfmpegResolver
+{
+ private const string _apiFfmpegInfoEndpoint = "https://evermeet.cx/ffmpeg/info/ffmpeg/release";
+ private const string _apiFfprobeInfoEndpoint = "https://evermeet.cx/ffmpeg/info/ffprobe/release";
+ private readonly string _tempDirectory = Path.GetTempPath();
+ private bool _isUpdating = false;
+ private readonly IHttpClientFactory _httpClientFactory;
+
+ ///
+ public override string FileName { get; } = "ffmpeg";
+
+ ///
+ /// Creates a service that checks, downloads, installs, and updates ffmpeg on MacOS.
+ ///
+ /// The HTTP client factory.
+ public FfmpegMacResolver(IHttpClientFactory httpClientFactory)
+ => _httpClientFactory = httpClientFactory;
+
+ ///
+ public override async ValueTask GetLatestVersionAsync(CancellationToken cToken = default)
+ {
+ var http = _httpClientFactory.CreateClient();
+ var response = await http.CallApiAsync(_apiFfmpegInfoEndpoint, cToken);
+
+ return response.Version;
+ }
+
+ ///
+ public override async ValueTask<(string? OldVersion, string? NewVersion)> InstallOrUpdateAsync(string dependenciesUri, CancellationToken cToken = default)
+ {
+ if (_isUpdating)
+ return (null, null);
+
+ _isUpdating = true;
+ var currentVersion = await GetCurrentVersionAsync(cToken);
+ var newVersion = await GetLatestVersionAsync(cToken);
+
+ // Update
+ if (currentVersion is not null)
+ {
+ // If the versions are the same, exit.
+ if (currentVersion == newVersion)
+ {
+ _isUpdating = false;
+ return (currentVersion, null);
+ }
+
+ Utilities.TryDeleteFile(Path.Combine(dependenciesUri, FileName));
+ Utilities.TryDeleteFile(Path.Combine(dependenciesUri, "ffprobe"));
+ }
+
+ // Install
+ Directory.CreateDirectory(dependenciesUri);
+
+ var http = _httpClientFactory.CreateClient();
+ var ffmpegResponse = await http.CallApiAsync(_apiFfmpegInfoEndpoint, cToken);
+ var ffprobeResponse = await http.CallApiAsync(_apiFfprobeInfoEndpoint, cToken);
+
+ await Task.WhenAll(
+ InstallDependencyAsync(ffmpegResponse, dependenciesUri, cToken),
+ InstallDependencyAsync(ffprobeResponse, dependenciesUri, cToken)
+ );
+
+ // Update environment variable
+ Utilities.AddPathToPATHEnvar(dependenciesUri);
+
+ _isUpdating = false;
+ return (currentVersion, newVersion);
+ }
+
+ ///
+ /// Install the dependency provided by .
+ ///
+ /// The dependency to be installed.
+ /// The absolute path to the directory where the dependency should be installed to.
+ /// The cancellation token.
+ private async Task InstallDependencyAsync(EvermeetInfo downloadInfo, string dependenciesUri, CancellationToken cToken = default)
+ {
+ var http = _httpClientFactory.CreateClient();
+ var downloadUrl = downloadInfo.Download["zip"].Url;
+ var zipFileName = downloadUrl[(downloadUrl.LastIndexOf('/') + 1)..];
+ var zipFilePath = Path.Combine(_tempDirectory, zipFileName);
+
+ // Download the zip file and save it to the temporary directory.
+ using var zipStream = await http.GetStreamAsync(downloadUrl, cToken);
+
+ using (var fileStream = new FileStream(zipFilePath, FileMode.Create))
+ await zipStream.CopyToAsync(fileStream, cToken);
+
+ // Extract the zip file.
+ ZipFile.ExtractToDirectory(zipFileName, _tempDirectory);
+
+ // Move the dependency binary.
+ var finalFileUri = Path.Combine(dependenciesUri, downloadInfo.Name);
+ File.Move(Path.Combine(_tempDirectory, downloadInfo.Name), finalFileUri, true);
+
+ // Mark binary as executable.
+ using var chmod = Utilities.StartProcess("chmod", $"+x \"{finalFileUri}\"");
+ await chmod.WaitForExitAsync(cToken);
+
+ // Cleanup.
+ File.Delete(zipFilePath);
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/Services/FfmpegWindowsResolver.cs b/EllieHub/Services/FfmpegWindowsResolver.cs
new file mode 100644
index 0000000..9857788
--- /dev/null
+++ b/EllieHub/Services/FfmpegWindowsResolver.cs
@@ -0,0 +1,112 @@
+using EllieHub.Services.Abstractions;
+using System.IO.Compression;
+using System.Runtime.InteropServices;
+using System.Runtime.Versioning;
+
+namespace EllieHub.Services;
+
+///
+/// Service that checks, downloads, installs, and updates ffmpeg on Windows.
+///
+/// Source: https://github.com/GyanD/codexffmpeg/releases/latest
+[SupportedOSPlatform("windows")]
+public sealed class FfmpegWindowsResolver : FfmpegResolver
+{
+ private readonly string _tempDirectory = Path.GetTempPath();
+ private bool _isUpdating = false;
+ private readonly IHttpClientFactory _httpClientFactory;
+
+ ///
+ public override string FileName { get; } = "ffmpeg.exe";
+
+ ///
+ /// Creates a service that checks, downloads, installs, and updates ffmpeg on Windows.
+ ///
+ /// The HTTP client factory.
+ public FfmpegWindowsResolver(IHttpClientFactory httpClientFactory)
+ => _httpClientFactory = httpClientFactory;
+
+ ///
+ public override ValueTask CanUpdateAsync(CancellationToken cToken = default)
+ {
+ // I could not find any ARM build of ffmpeg for Windows.
+ return (RuntimeInformation.OSArchitecture is Architecture.X64)
+ ? base.CanUpdateAsync(cToken)
+ : ValueTask.FromResult(false);
+ }
+
+ ///
+ public override async ValueTask GetLatestVersionAsync(CancellationToken cToken = default)
+ {
+ var http = _httpClientFactory.CreateClient(AppConstants.NoRedirectClient);
+
+ var response = await http.GetAsync("https://github.com/GyanD/codexffmpeg/releases/latest", cToken);
+
+ var lastSlashIndex = response.Headers.Location?.OriginalString.LastIndexOf('/')
+ ?? throw new InvalidOperationException("Failed to get the latest yt-dlp version.");
+
+ return response.Headers.Location.OriginalString[(lastSlashIndex + 1)..];
+ }
+
+ ///
+ public override async ValueTask<(string? OldVersion, string? NewVersion)> InstallOrUpdateAsync(string dependenciesUri, CancellationToken cToken = default)
+ {
+ if (_isUpdating)
+ return (null, null);
+
+ _isUpdating = true;
+ var currentVersion = await GetCurrentVersionAsync(cToken);
+ var newVersion = await GetLatestVersionAsync(cToken);
+
+ // Update
+ if (currentVersion is not null)
+ {
+ // If the versions are the same, exit.
+ if (currentVersion == newVersion)
+ {
+ _isUpdating = false;
+ return (currentVersion, null);
+ }
+
+ Utilities.TryDeleteFile(Path.Combine(dependenciesUri, FileName));
+ Utilities.TryDeleteFile(Path.Combine(dependenciesUri, "ffprobe.exe"));
+ //Utilities.TryDeleteFile(Path.Combine(dependenciesUri, "ffplay.exe"));
+ }
+
+ // Install
+ Directory.CreateDirectory(dependenciesUri);
+
+ var zipFileName = $"ffmpeg-{newVersion}-full_build.zip";
+ var http = _httpClientFactory.CreateClient();
+ using var downloadStream = await http.GetStreamAsync($"https://github.com/GyanD/codexffmpeg/releases/download/{newVersion}/{zipFileName}", cToken);
+
+ // Save zip file to the temporary directory.
+ var zipFilePath = Path.Combine(_tempDirectory, zipFileName);
+ var zipExtractDir = Path.Combine(_tempDirectory, zipFileName[..^4]);
+ using (var fileStream = new FileStream(zipFilePath, FileMode.Create))
+ await downloadStream.CopyToAsync(fileStream, cToken);
+
+ // Schedule installation to the thread-pool because ffmpeg is pretty
+ // large and doing I/O with it can potentially block the UI thread.
+ await Task.Run(() =>
+ {
+ // Extract the zip file.
+ ZipFile.ExtractToDirectory(zipFilePath, _tempDirectory);
+
+ // Move ffmpeg to the dependencies directory.
+ File.Move(Path.Combine(zipExtractDir, "bin", FileName), Path.Combine(dependenciesUri, FileName), true);
+ File.Move(Path.Combine(zipExtractDir, "bin", "ffprobe.exe"), Path.Combine(dependenciesUri, "ffprobe.exe"), true);
+ //File.Move(Path.Combine(zipExtractDir, "bin", "ffplay.exe"), Path.Combine(dependenciesUri, "ffplay.exe"));
+
+ // Cleanup
+ File.Delete(zipFilePath);
+ Directory.Delete(zipExtractDir, true);
+ }, cToken);
+
+ // Update environment variable
+ Utilities.AddPathToPATHEnvar(dependenciesUri);
+
+ _isUpdating = false;
+ return (currentVersion, newVersion);
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/Services/LogWriter.cs b/EllieHub/Services/LogWriter.cs
new file mode 100644
index 0000000..60f519c
--- /dev/null
+++ b/EllieHub/Services/LogWriter.cs
@@ -0,0 +1,94 @@
+using EllieHub.Models.Config;
+using EllieHub.Models.EventArguments;
+using EllieHub.Services.Abstractions;
+using System.Diagnostics.CodeAnalysis;
+using System.Text;
+
+namespace EllieHub.Services;
+
+///
+/// Defines a service that writes logs of bot instances to the disk.
+///
+public sealed class LogWriter : ILogWriter
+{
+ private readonly Dictionary _botLogs = new();
+ private readonly ReadOnlyAppConfig _appConfig;
+
+ ///
+ public event EventHandler? OnLogCreated;
+
+ ///
+ /// Creates a service that writes logs of bot instances to the disk.
+ ///
+ /// The application settings.
+ public LogWriter(ReadOnlyAppConfig appConfig)
+ => _appConfig = appConfig;
+
+ ///
+ public async Task FlushAllAsync(bool removeFromMemory = false, CancellationToken cToken = default)
+ {
+ var result = await Task.WhenAll(_botLogs.Keys.Select(x => FlushAsync(x, removeFromMemory, cToken)));
+ return result.Any(x => x);
+ }
+
+ ///
+ public async Task FlushAsync(Guid botId, bool removeFromMemory = false, CancellationToken cToken = default)
+ {
+ if (!_botLogs.TryGetValue(botId, out var logStringBuilder))
+ return false;
+
+ if (removeFromMemory)
+ _botLogs.Remove(botId);
+
+ if (logStringBuilder.Length is 0)
+ return false;
+
+ Directory.CreateDirectory(_appConfig.LogsDirectoryUri);
+ var logByteSize = (logStringBuilder.Length * 2) + 1;
+ var botEntry = _appConfig.BotEntries[botId];
+ var now = DateTimeOffset.Now;
+ var date = new DateOnly(now.Year, now.Month, now.Day).ToShortDateString().Replace('/', '-');
+ var fileUri = Path.Combine(_appConfig.LogsDirectoryUri, $"{botEntry.Name}_{date}-{now.ToUnixTimeSeconds()}.txt");
+
+ await File.WriteAllTextAsync(fileUri, logStringBuilder.ToString(), cToken);
+
+ logStringBuilder.Clear();
+
+ OnLogCreated?.Invoke(this, new(fileUri, logByteSize, now));
+
+ return true;
+ }
+
+ ///
+ public bool TryAdd(Guid botId, string message)
+ {
+ if (string.IsNullOrWhiteSpace(message) || _appConfig.LogMaxSizeMb <= 0.0)
+ return false;
+
+ if (!_botLogs.TryGetValue(botId, out var logStringBuilder))
+ {
+ logStringBuilder = new();
+ _botLogs.TryAdd(botId, logStringBuilder);
+ }
+
+ logStringBuilder.AppendLine(message);
+
+ if ((logStringBuilder.Length > _appConfig.LogMaxSizeMb * 1_000_000))
+ _ = FlushAsync(botId);
+
+ return true;
+ }
+
+ ///
+ public bool TryRead(Guid botId, [MaybeNullWhen(false)] out string log)
+ {
+ if (_botLogs.TryGetValue(botId, out var logStringBuilder))
+ {
+ log = logStringBuilder.ToString();
+ return true;
+ }
+
+ log = null;
+ return false;
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/Services/Mocks/FfmpegMockResolver.cs b/EllieHub/Services/Mocks/FfmpegMockResolver.cs
new file mode 100644
index 0000000..1d179a6
--- /dev/null
+++ b/EllieHub/Services/Mocks/FfmpegMockResolver.cs
@@ -0,0 +1,30 @@
+using EllieHub.Services.Abstractions;
+
+namespace EllieHub.Services.Mocks;
+
+///
+/// Service that pretends to check, download, install, and update ffmpeg.
+///
+internal sealed class FfmpegMockResolver : FfmpegResolver
+{
+ private const string _currentVersion = "6.0";
+
+ ///
+ public override string FileName { get; } = "ffmpeg";
+
+ ///
+ public override ValueTask CanUpdateAsync(CancellationToken cToken = default)
+ => ValueTask.FromResult(false);
+
+ ///
+ public override ValueTask GetCurrentVersionAsync(CancellationToken cToken = default)
+ => ValueTask.FromResult(_currentVersion);
+
+ ///
+ public override ValueTask GetLatestVersionAsync(CancellationToken cToken = default)
+ => ValueTask.FromResult(_currentVersion);
+
+ ///
+ public override ValueTask<(string? OldVersion, string? NewVersion)> InstallOrUpdateAsync(string dependenciesUri, CancellationToken cToken = default)
+ => ValueTask.FromResult<(string?, string?)>((_currentVersion, null));
+}
\ No newline at end of file
diff --git a/EllieHub/Services/Mocks/MockAppConfigManager.cs b/EllieHub/Services/Mocks/MockAppConfigManager.cs
new file mode 100644
index 0000000..ff254c2
--- /dev/null
+++ b/EllieHub/Services/Mocks/MockAppConfigManager.cs
@@ -0,0 +1,34 @@
+using EllieHub.Models;
+using EllieHub.Models.Config;
+using EllieHub.Services.Abstractions;
+
+namespace EllieHub.Services.Mocks;
+
+///
+/// Represents a service that pretends to manage the application's settings.
+///
+internal sealed class MockAppConfigManager : IAppConfigManager
+{
+ ///
+ public ReadOnlyAppConfig AppConfig { get; } = new(new() { BotEntries = new() { [Guid.Empty] = new("MockBot", Path.Combine(AppStatics.AppDefaultBotDirectoryUri, "MockBot"), 0) } });
+
+ ///
+ public ValueTask CreateBotEntryAsync(CancellationToken cToken = default)
+ => ValueTask.FromResult(new BotEntry(Guid.Empty, AppConfig.BotEntries[Guid.Empty]));
+
+ ///
+ public ValueTask DeleteBotEntryAsync(Guid id, CancellationToken cToken = default)
+ => ValueTask.FromResult(null);
+
+ ///
+ public ValueTask SwapBotEntryAsync(Guid firstBotId, Guid secondBotId, CancellationToken cToken = default)
+ => ValueTask.FromResult(false);
+
+ ///
+ public ValueTask UpdateBotEntryAsync(Guid id, Func selector, CancellationToken cToken = default)
+ => ValueTask.FromResult(false);
+
+ ///
+ public ValueTask UpdateConfigAsync(Action action, CancellationToken cToken = default)
+ => ValueTask.CompletedTask;
+}
\ No newline at end of file
diff --git a/EllieHub/Services/Mocks/MockEllieResolver.cs b/EllieHub/Services/Mocks/MockEllieResolver.cs
new file mode 100644
index 0000000..304edfd
--- /dev/null
+++ b/EllieHub/Services/Mocks/MockEllieResolver.cs
@@ -0,0 +1,41 @@
+using EllieHub.Services.Abstractions;
+
+namespace EllieHub.Services.Mocks;
+
+///
+/// Defines a service that pretends to check, download, install, and update a bot instance.
+///
+internal sealed class MockEllieResolver : IBotResolver
+{
+ ///
+ public string BotName { get; } = "MockBot";
+
+ ///
+ public Guid Id { get; } = Guid.Empty;
+
+ ///
+ public string DependencyName { get; } = "NadekoBot";
+
+ ///
+ public string FileName { get; } = "NadekoBot";
+
+ ///
+ public ValueTask CanUpdateAsync(CancellationToken cToken = default)
+ => ValueTask.FromResult(false);
+
+ ///
+ public ValueTask CreateBackupAsync()
+ => ValueTask.FromResult(null);
+
+ ///
+ public ValueTask GetCurrentVersionAsync(CancellationToken cToken = default)
+ => ValueTask.FromResult("4.4.4");
+
+ ///
+ public ValueTask GetLatestVersionAsync(CancellationToken cToken = default)
+ => ValueTask.FromResult("4.4.4");
+
+ ///
+ public ValueTask<(string? OldVersion, string? NewVersion)> InstallOrUpdateAsync(string dependenciesUri, CancellationToken cToken = default)
+ => ValueTask.FromResult<(string?, string?)>(("4.4.4", null));
+}
\ No newline at end of file
diff --git a/EllieHub/Services/YtdlpResolver.cs b/EllieHub/Services/YtdlpResolver.cs
new file mode 100644
index 0000000..54421f8
--- /dev/null
+++ b/EllieHub/Services/YtdlpResolver.cs
@@ -0,0 +1,171 @@
+using Microsoft.Extensions.Caching.Memory;
+using EllieHub.Services.Abstractions;
+using System.Runtime.InteropServices;
+
+namespace EllieHub.Services;
+
+///
+/// Service that checks, downloads, installs, and updates yt-dlp.
+///
+/// Source: https://github.com/yt-dlp/yt-dlp/releases/latest
+public sealed class YtdlpResolver : IYtdlpResolver
+{
+ private const string _cachedCurrentVersionKey = "currentVersion:yt-dlp";
+ private const string _ytdlpProcessName = "yt-dlp";
+ private static readonly string _downloadedFileName = GetDownloadFileName();
+ private bool _isUpdating = false;
+ private readonly IHttpClientFactory _httpClientFactory;
+ private readonly IMemoryCache _memoryCache;
+
+ ///
+ public string DependencyName { get; } = "Youtube-dlp";
+
+ ///
+ public string FileName { get; } = (OperatingSystem.IsWindows()) ? "yt-dlp.exe" : "yt-dlp";
+
+ ///
+ /// Creates a service that checks, downloads, installs, and updates yt-dlp.
+ ///
+ /// The HTTP client factory.
+ /// The memory cache.
+ public YtdlpResolver(IHttpClientFactory httpClientFactory, IMemoryCache memoryCache)
+ {
+ _httpClientFactory = httpClientFactory;
+ _memoryCache = memoryCache;
+ }
+
+ ///
+ public async ValueTask CanUpdateAsync(CancellationToken cToken = default)
+ {
+ var currentVersion = await GetCurrentVersionAsync(cToken);
+
+ if (currentVersion is null)
+ return null;
+
+ var latestVersion = await GetLatestVersionAsync(cToken);
+
+ if (latestVersion.Equals(currentVersion, StringComparison.Ordinal))
+ return false;
+
+ var http = _httpClientFactory.CreateClient();
+
+ return await http.IsUrlValidAsync($"https://github.com/yt-dlp/yt-dlp/releases/download/{latestVersion}/{_downloadedFileName}", cToken);
+ }
+
+ ///
+ public async ValueTask GetCurrentVersionAsync(CancellationToken cToken = default)
+ {
+ // If yt-dlp is not accessible from the shell...
+ if (!await Utilities.ProgramExistsAsync(_ytdlpProcessName, cToken))
+ {
+ // And doesn't exist in the dependencies folder,
+ // report that yt-dlp is not installed.
+ if (!File.Exists(Path.Combine(AppStatics.AppDepsUri, FileName)))
+ return null;
+
+ // Else, add the dependencies directory to the PATH envar,
+ // then try again.
+ Utilities.AddPathToPATHEnvar(AppStatics.AppDepsUri);
+ return await GetCurrentVersionAsync(cToken);
+ }
+
+ // "yt-dlp --version" takes a very long time to return, so we cache the result for 90 seconds.
+ if (_memoryCache.TryGetValue(_cachedCurrentVersionKey, out var currentVersion) && currentVersion is not null)
+ return currentVersion;
+
+ using var ytdlp = Utilities.StartProcess(_ytdlpProcessName, "--version");
+
+ var currentProcessVersion = (await ytdlp.StandardOutput.ReadToEndAsync(cToken)).Trim();
+ _memoryCache.Set(_cachedCurrentVersionKey, currentProcessVersion, TimeSpan.FromMinutes(1.5));
+
+ return currentProcessVersion;
+ }
+
+ ///
+ public async ValueTask GetLatestVersionAsync(CancellationToken cToken = default)
+ {
+ var http = _httpClientFactory.CreateClient(AppConstants.NoRedirectClient);
+
+ var response = await http.GetAsync("https://github.com/yt-dlp/yt-dlp/releases/latest", cToken);
+
+ var lastSlashIndex = response.Headers.Location?.OriginalString.LastIndexOf('/')
+ ?? throw new InvalidOperationException("Failed to get the latest yt-dlp version.");
+
+ return response.Headers.Location.OriginalString[(lastSlashIndex + 1)..];
+ }
+
+ ///
+ public async ValueTask<(string? OldVersion, string? NewVersion)> InstallOrUpdateAsync(string dependenciesUri, CancellationToken cToken = default)
+ {
+ if (_isUpdating)
+ return (null, null);
+
+ _isUpdating = true;
+ var currentVersion = await GetCurrentVersionAsync(cToken);
+ var newVersion = await GetLatestVersionAsync(cToken);
+
+ _memoryCache.Remove(_cachedCurrentVersionKey);
+
+ // Update
+ if (currentVersion is not null)
+ {
+ // If the versions are the same, exit.
+ if (currentVersion == newVersion)
+ {
+ _isUpdating = false;
+ return (currentVersion, null);
+ }
+
+ using var ytdlp = Utilities.StartProcess(_ytdlpProcessName, "-U");
+ await ytdlp.WaitForExitAsync(cToken);
+
+ _isUpdating = false;
+ return (currentVersion, newVersion);
+ }
+
+ // Install
+ Directory.CreateDirectory(dependenciesUri);
+
+ var finalFilePath = Path.Combine(dependenciesUri, FileName);
+ var http = _httpClientFactory.CreateClient();
+ using var downloadStream = await http.GetStreamAsync($"https://github.com/yt-dlp/yt-dlp/releases/download/{newVersion}/{_downloadedFileName}", cToken);
+ using (var fileStream = new FileStream(finalFilePath, FileMode.Create))
+ await downloadStream.CopyToAsync(fileStream, cToken);
+
+ // Update environment variable
+ Utilities.AddPathToPATHEnvar(dependenciesUri);
+
+ // On Linux and MacOS, we need to mark the file as executable.
+ if (Environment.OSVersion.Platform is PlatformID.Unix)
+ {
+ using var chmod = Utilities.StartProcess("chmod", $"+x \"{finalFilePath}\"");
+ await chmod.WaitForExitAsync(cToken);
+ }
+
+ _isUpdating = false;
+ return (null, newVersion);
+ }
+
+ ///
+ /// Gets the name of the yt-dlp binary file to be downloaded.
+ ///
+ /// The name of the yt-dlp binary file.
+ /// Occurs when this method is executed in an unsupported platform.
+ private static string GetDownloadFileName()
+ {
+ return RuntimeInformation.OSArchitecture switch
+ {
+ // Windows
+ Architecture.X64 when OperatingSystem.IsWindows() => "yt-dlp.exe",
+
+ // Linux
+ Architecture.X64 when OperatingSystem.IsLinux() => "yt-dlp_linux",
+ Architecture.Arm64 when OperatingSystem.IsLinux() => "yt-dlp_linux_aarch64",
+
+ // MacOS
+ Architecture.X64 when OperatingSystem.IsMacOS() => "yt-dlp_macos_legacy",
+ Architecture.Arm64 when OperatingSystem.IsMacOS() => "yt-dlp_macos",
+ _ => throw new NotSupportedException($"Architecture of type {RuntimeInformation.OSArchitecture} is not supported by yt-dlp on this OS.")
+ };
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/Styles/EllieStyles.axaml b/EllieHub/Styles/EllieStyles.axaml
new file mode 100644
index 0000000..b831792
--- /dev/null
+++ b/EllieHub/Styles/EllieStyles.axaml
@@ -0,0 +1,151 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/EllieHub/Usings.cs b/EllieHub/Usings.cs
new file mode 100644
index 0000000..359ff14
--- /dev/null
+++ b/EllieHub/Usings.cs
@@ -0,0 +1,3 @@
+global using Toastie.Events;
+global using EllieHub.Common;
+global using EllieHub.Extensions;
\ No newline at end of file
diff --git a/EllieHub/ViewLocator.cs b/EllieHub/ViewLocator.cs
new file mode 100644
index 0000000..c5e0866
--- /dev/null
+++ b/EllieHub/ViewLocator.cs
@@ -0,0 +1,27 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Templates;
+using Avalonia.Media;
+using EllieHub.ViewModels.Abstractions;
+
+namespace EllieHub;
+
+///
+/// An object that creates controls out of view-models.
+///
+public sealed class ViewLocator : IDataTemplate
+{
+ ///
+ public bool Match(object? data)
+ => data is ViewModelBase;
+
+ ///
+ /// Receives a view-model and returns its corresponding view.
+ public Control Build(object? data)
+ {
+ return (data is ViewModelBase viewModel && viewModel.GetType().BaseType?.GenericTypeArguments[0] is Type controlType)
+ ? (Application.Current as App)?.Services.GetService(controlType) as Control
+ ?? new TextBlock { Text = $"View-model of type \"{data?.GetType().FullName ?? "null"}\" is not registered in the IoC container.", TextWrapping = TextWrapping.WrapWithOverflow }
+ : new TextBlock { Text = $"Component of type \"{data?.GetType().FullName ?? "null"}\" is not a valid view-model.", TextWrapping = TextWrapping.WrapWithOverflow };
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/ViewModels/Abstractions/ViewModelBase.cs b/EllieHub/ViewModels/Abstractions/ViewModelBase.cs
new file mode 100644
index 0000000..6eb20ad
--- /dev/null
+++ b/EllieHub/ViewModels/Abstractions/ViewModelBase.cs
@@ -0,0 +1,15 @@
+using ReactiveUI;
+
+namespace EllieHub.ViewModels.Abstractions;
+
+///
+/// The base view-model.
+///
+public abstract class ViewModelBase : ReactiveObject, IActivatableViewModel
+{
+ ///
+ /// Activates this view-model with ReactiveUI. See for more information.
+ ///
+ /// Activation must also be set up in the corresponding view of this view-model.
+ public ViewModelActivator Activator { get; } = new();
+}
\ No newline at end of file
diff --git a/EllieHub/ViewModels/Abstractions/ViewModelBaseGeneric.cs b/EllieHub/ViewModels/Abstractions/ViewModelBaseGeneric.cs
new file mode 100644
index 0000000..9a70bb1
--- /dev/null
+++ b/EllieHub/ViewModels/Abstractions/ViewModelBaseGeneric.cs
@@ -0,0 +1,15 @@
+using ReactiveUI;
+
+namespace EllieHub.ViewModels.Abstractions;
+
+///
+/// The base view-model.
+///
+/// The type of the view this view-model is associated with.
+public abstract class ViewModelBase : ViewModelBase where T : IViewFor
+{
+ ///
+ /// The type of the view this view-model is associated with.
+ ///
+ public Type ViewType { get; } = typeof(T);
+}
\ No newline at end of file
diff --git a/EllieHub/ViewModels/Controls/BotConfigViewModel.cs b/EllieHub/ViewModels/Controls/BotConfigViewModel.cs
new file mode 100644
index 0000000..edf30ba
--- /dev/null
+++ b/EllieHub/ViewModels/Controls/BotConfigViewModel.cs
@@ -0,0 +1,498 @@
+using Avalonia.Controls;
+using MsBox.Avalonia.Enums;
+using EllieHub.Enums;
+using EllieHub.Models.EventArguments;
+using EllieHub.Services.Abstractions;
+using EllieHub.ViewModels.Abstractions;
+using EllieHub.Views.Controls;
+using EllieHub.Views.Windows;
+using ReactiveUI;
+using SkiaSharp;
+using System.Diagnostics;
+using System.Reactive.Disposables;
+using System.Runtime.InteropServices;
+
+namespace EllieHub.ViewModels.Controls;
+
+///
+/// View-model for , the window with settings and controls for a specific bot instance.
+///
+public class BotConfigViewModel : ViewModelBase, IDisposable
+{
+ private string _botName = string.Empty;
+ private string _directoryHint = string.Empty;
+ private bool _isBotRunning;
+ private bool _isIdle;
+ private bool _areButtonsUnlocked;
+ private SKBitmap _botAvatar;
+ private readonly IAppConfigManager _appConfigManager;
+ private readonly AppView _mainWindow;
+ private readonly IBotOrchestrator _botOrchestrator;
+ private readonly ILogWriter _logWriter;
+
+ ///
+ /// Raised when the user deletes the bot instance associated with this view-model.
+ ///
+ public event AsyncEventHandler? BotDeleted;
+
+ ///
+ /// Raised when the user sets a new avatar for the bot instance associated with this view-model.
+ ///
+ public event AsyncEventHandler? AvatarChanged;
+
+
+
+ ///
+ /// The name of the bot as defined in the settings file.
+ ///
+ protected string ActualBotName
+ => _appConfigManager.AppConfig.BotEntries[Resolver.Id].Name;
+
+ ///
+ /// The bot resolver to be used.
+ ///
+ public IBotResolver Resolver { get; }
+
+ ///
+ /// The Id of the bot associated with this view-model.
+ ///
+ public Guid Id { get; }
+
+ ///
+ /// The bar that defines where the bot instance should be saved to.
+ ///
+ public UriInputBarViewModel BotDirectoryUriBar { get; }
+
+ ///
+ /// The bar to update the bot instance.
+ ///
+ public DependencyButtonViewModel UpdateBar { get; }
+
+ ///
+ /// The fake console that displays the bot's output.
+ ///
+ public FakeConsoleViewModel FakeConsole { get; }
+
+ ///
+ /// The bot avatar to be displayed on the front-end.
+ ///
+ public SKBitmap BotAvatar
+ {
+ get => _botAvatar;
+ set => this.RaiseAndSetIfChanged(ref _botAvatar, value);
+ }
+
+ ///
+ /// The hint for .
+ ///
+ public string DirectoryHint
+ {
+ get => _directoryHint;
+ private set => this.RaiseAndSetIfChanged(ref _directoryHint, value);
+ }
+
+ ///
+ /// The name of the bot.
+ ///
+ public string BotName
+ {
+ get => _botName;
+ set
+ {
+ var sanitizedValue = value.ReplaceLineEndings(string.Empty);
+
+ DirectoryHint = $"Select the absolute path to the bot directory. For example: {Path.Combine(_appConfigManager.AppConfig.BotsDirectoryUri, sanitizedValue)}";
+ this.RaiseAndSetIfChanged(ref _botName, sanitizedValue);
+ }
+ }
+
+ ///
+ /// Determines whether if a long-running setting option is currently in progress.
+ ///
+ public bool AreButtonsUnlocked
+ {
+ get => _areButtonsUnlocked;
+ private set => this.RaiseAndSetIfChanged(ref _areButtonsUnlocked, value);
+ }
+
+ ///
+ /// Determines whether this view-model is undergoing an operation or not.
+ ///
+ public bool IsIdle
+ {
+ get => _isIdle;
+ private set => this.RaiseAndSetIfChanged(ref _isIdle, value);
+ }
+
+ ///
+ /// Determines whether the bot associated with this view-model is running or not.
+ ///
+ public bool IsBotRunning
+ {
+ get => _isBotRunning;
+ private set => this.RaiseAndSetIfChanged(ref _isBotRunning, value);
+ }
+
+ ///
+ /// Creates a view-model for .
+ ///
+ /// The app settings manager.
+ /// The main window of the application.
+ /// The text box with the path to the directory where the bot instance is.
+ /// The bar for updating the bot.
+ /// The fake console to write the bot's output to.
+ /// The bot resolver to be used.
+ /// The bot orchestrator.
+ /// The service responsible for creating log files.
+ public BotConfigViewModel(IAppConfigManager appConfigManager, AppView mainWindow, UriInputBarViewModel botDirectoryUriBar, DependencyButtonViewModel updateBotBar,
+ FakeConsoleViewModel fakeConsole, IBotResolver botResolver, IBotOrchestrator botOrchestrator, ILogWriter logWriter)
+ {
+ _appConfigManager = appConfigManager;
+ _mainWindow = mainWindow;
+ _botOrchestrator = botOrchestrator;
+ _logWriter = logWriter;
+ BotDirectoryUriBar = botDirectoryUriBar;
+ UpdateBar = updateBotBar;
+ FakeConsole = fakeConsole;
+
+ UpdateBar.Click += InstallOrUpdateAsync;
+ _botOrchestrator.OnStdout += WriteLog;
+ _botOrchestrator.OnStderr += WriteLog;
+ _botOrchestrator.OnBotExit += LogBotExit;
+ _botOrchestrator.OnBotExit += ReenableButtonsOnBotExit;
+
+ var botEntry = _appConfigManager.AppConfig.BotEntries[botResolver.Id];
+
+ _logWriter.TryRead(botResolver.Id, out var logContent);
+ FakeConsole.Content = logContent ?? string.Empty;
+ FakeConsole.Watermark = "Waiting for the bot to start...";
+ Resolver = botResolver;
+ BotDirectoryUriBar.CurrentUri = botEntry.InstanceDirectoryUri;
+ _botAvatar = Utilities.LoadLocalImage(botEntry.AvatarUri);
+ BotName = botResolver.BotName;
+ Id = botResolver.Id;
+ UpdateBar.DependencyName = "Checking...";
+ IsBotRunning = botOrchestrator.IsBotRunning(botResolver.Id);
+
+ if (IsBotRunning)
+ EnableButtons(true, false);
+ else
+ EnableButtons(!Directory.Exists(botEntry.InstanceDirectoryUri), true);
+
+ _ = LoadUpdateBarAsync(botResolver, updateBotBar);
+
+ // Dispose when the view is deactivated
+ this.WhenActivated(disposables => Disposable.Create(() => Dispose()).DisposeWith(disposables));
+ }
+
+ ///
+ /// Moves or renames the bot instance associated with this view-model.
+ ///
+ public async ValueTask MoveOrRenameAsync()
+ {
+ var wereButtonsUnlocked = AreButtonsUnlocked;
+ EnableButtons(true, false);
+
+ var botEntry = _appConfigManager.AppConfig.BotEntries[Resolver.Id];
+ var oldName = botEntry.Name;
+ var oldUri = botEntry.InstanceDirectoryUri;
+ var hasNewUri = !BotDirectoryUriBar.CurrentUri.Equals(oldUri, StringComparison.OrdinalIgnoreCase);
+
+ try
+ {
+ // Move/Rename the directory.
+ if (hasNewUri && Directory.Exists(oldUri))
+ {
+ Directory.CreateDirectory(Directory.GetParent(BotDirectoryUriBar.CurrentUri)?.FullName ?? string.Empty);
+
+ // If destination directory exists but is empty, it's safe to delete it so moving the bot doesn't fail
+ // If destination directory exists but is not empty, throw exception
+ if (Directory.Exists(BotDirectoryUriBar.CurrentUri))
+ {
+ if (Directory.EnumerateFileSystemEntries(BotDirectoryUriBar.CurrentUri).Any())
+ throw new InvalidOperationException($"Cannot move {oldName} because the destination folder is not empty.");
+ else
+ Directory.Delete(BotDirectoryUriBar.CurrentUri);
+ }
+
+ if (Environment.OSVersion.Platform is not PlatformID.Unix)
+ Directory.Move(oldUri, BotDirectoryUriBar.CurrentUri);
+ else
+ {
+ // Move the bot root directory with "mv" to circumvent this issue on Unix systems: https://github.com/dotnet/runtime/issues/31149
+ using var moveProcess = Utilities.StartProcess("mv", $"\"{oldUri}\" \"{BotDirectoryUriBar.CurrentUri}\"");
+ await moveProcess.WaitForExitAsync();
+ }
+
+ BotDirectoryUriBar.RecheckCurrentUri();
+ }
+
+ // Update the application settings.
+ if (hasNewUri || !BotName.Equals(oldName, StringComparison.OrdinalIgnoreCase))
+ {
+ await _appConfigManager.UpdateBotEntryAsync(Id, x => x with
+ {
+ Name = BotName,
+ InstanceDirectoryUri = BotDirectoryUriBar.CurrentUri
+ });
+ }
+ }
+ catch (Exception ex)
+ {
+ await _mainWindow.ShowDialogWindowAsync($"An error occurred while moving/renaming {oldName}:\n{ex.Message}", DialogType.Error, Icon.Error);
+ BotDirectoryUriBar.CurrentUri = oldUri;
+ }
+ finally
+ {
+ EnableButtons(!wereButtonsUnlocked, true);
+ }
+ }
+
+ ///
+ /// Opens a file in the bot directory with the default program associated with it.
+ ///
+ /// The name of the file.
+ /// Occurs when there is no program available to open the specified file.
+ public async ValueTask OpenFileAsync(string fileName)
+ {
+ try
+ {
+ var fileUri = Directory.EnumerateFiles(_appConfigManager.AppConfig.BotEntries[Resolver.Id].InstanceDirectoryUri, fileName, SearchOption.AllDirectories)
+ .First(x => x.Contains(fileName, StringComparison.Ordinal));
+
+ var process = Process.Start(new ProcessStartInfo()
+ {
+ FileName = fileUri,
+ UseShellExecute = true,
+ }) ?? throw new InvalidOperationException($"Failed opening {fileName}. There is no program association for files of type '{fileName[(fileName.LastIndexOf('.') + 1)..]}'.");
+
+ process.EnableRaisingEvents = true;
+ process.Exited += (sender, _) => (sender as Process)?.Dispose();
+ }
+ catch (Exception ex)
+ {
+ await _mainWindow.ShowDialogWindowAsync($"An error occurred while opening {fileName}:\n{ex.Message}", DialogType.Error, Icon.Error);
+ }
+ }
+
+ ///
+ /// Associates an avatar to the bot instance of this view-model.
+ ///
+ public async ValueTask SaveAvatarAsync()
+ {
+ var imageFileStorage = await _mainWindow.StorageProvider.OpenFilePickerAsync(AppStatics.ImageFilePickerOptions);
+
+ if (imageFileStorage.Count is 0)
+ return;
+
+ // Save the Uri to the image file
+ var imageUri = imageFileStorage[0].Path.LocalPath;
+
+ await _appConfigManager.UpdateBotEntryAsync(Resolver.Id, x => x with { AvatarUri = imageUri });
+
+ // Set the new avatar
+ BotAvatar.Dispose();
+ BotAvatar = Utilities.LoadLocalImage(imageUri);
+
+ // Invoke event
+ AvatarChanged?.Invoke(this, new(Id, BotAvatar, imageUri));
+ }
+
+ ///
+ /// Creates a backup of the bot instance associated with this view-model.
+ ///
+ public async ValueTask BackupBotAsync()
+ {
+ EnableButtons(true, false);
+
+ var backupUri = await Resolver.CreateBackupAsync();
+
+ await ((string.IsNullOrWhiteSpace(backupUri))
+ ? _mainWindow.ShowDialogWindowAsync($"Bot {ActualBotName} not found.", DialogType.Error, Icon.Error)
+ : _mainWindow.ShowDialogWindowAsync($"Successfully backed up {ActualBotName} to:{Environment.NewLine}{backupUri}", iconType: Icon.Success));
+
+ EnableButtons(false, true);
+ }
+
+ ///
+ /// Deletes the bot instance associated with this view-model.
+ ///
+ public async ValueTask DeleteBotAsync()
+ {
+ var buttonPressed = await _mainWindow.ShowDialogWindowAsync(new()
+ {
+ ButtonDefinitions = ButtonEnum.OkCancel,
+ ContentTitle = "Are you sure?",
+ ContentMessage = $"Are you sure you want to delete {ActualBotName}?{Environment.NewLine}This action cannot be undone.",
+ MaxWidth = int.Parse(WindowConstants.DefaultWindowWidth) / 2.0,
+ SizeToContent = SizeToContent.WidthAndHeight,
+ ShowInCenter = true,
+ WindowIcon = _mainWindow.GetResource(AppResources.EllieHubIcon),
+ WindowStartupLocation = WindowStartupLocation.CenterOwner
+ });
+
+ if (buttonPressed is not ButtonResult.Ok)
+ return;
+
+ EnableButtons(true, false);
+
+ // Stop the bot instance
+ _botOrchestrator.Stop(Resolver.Id);
+
+ // Cleanup
+ FakeConsole.Content = string.Empty;
+ await _logWriter.FlushAsync(Resolver.Id, true);
+
+ UpdateBar.Click -= InstallOrUpdateAsync;
+ _botOrchestrator.OnStdout -= WriteLog;
+ _botOrchestrator.OnStderr -= WriteLog;
+ _botOrchestrator.OnBotExit -= LogBotExit;
+ _botOrchestrator.OnBotExit -= ReenableButtonsOnBotExit;
+
+ // Trigger delete event
+ BotDeleted?.Invoke(this, EventArgs.Empty);
+ }
+
+ ///
+ /// Installs or updates the bot instance associated with this view-model.
+ ///
+ /// The view-model of the dependency button that was pressed.
+ /// The event arguments.
+ public async Task InstallOrUpdateAsync(DependencyButtonViewModel dependencyButton, EventArgs eventArgs)
+ {
+ if (_botOrchestrator.IsBotRunning(Resolver.Id))
+ {
+ await _mainWindow.ShowDialogWindowAsync("Please, stop the bot before updating it.", DialogType.Warning, Icon.Warning);
+ return;
+ }
+
+ EnableButtons(true, false);
+
+ var originalStatus = UpdateBar.Status;
+ UpdateBar.Status = DependencyStatus.Updating;
+
+ try
+ {
+ var dialogWindowTask = await Resolver.InstallOrUpdateAsync(_appConfigManager.AppConfig.BotEntries[Resolver.Id].InstanceDirectoryUri) switch
+ {
+ (string oldVer, null) => _mainWindow.ShowDialogWindowAsync($"{Resolver.DependencyName} is already up-to-date (v{oldVer})."),
+ (null, string newVer) => _mainWindow.ShowDialogWindowAsync($"{Resolver.DependencyName} v{newVer} was successfully installed.", iconType: Icon.Success),
+ (string oldVer, string newVer) => _mainWindow.ShowDialogWindowAsync($"{Resolver.DependencyName} was successfully updated from version {oldVer} to version {newVer}.", iconType: Icon.Success),
+ (null, null) => _mainWindow.ShowDialogWindowAsync($"Update of {Resolver.DependencyName} is ongoing.", DialogType.Warning, Icon.Warning)
+ };
+
+ await dialogWindowTask;
+ _ = LoadUpdateBarAsync(Resolver, UpdateBar);
+
+ BotDirectoryUriBar.RecheckCurrentUri();
+ EnableButtons(false, true);
+ }
+ catch (Exception ex)
+ {
+ await _mainWindow.ShowDialogWindowAsync($"An error occurred while updating {Resolver.DependencyName}:\n{ex.Message}", DialogType.Error, Icon.Error);
+ UpdateBar.Status = originalStatus;
+ }
+ }
+
+ ///
+ /// Starts the bot instance associated with this view-model.
+ ///
+ public void StartBot()
+ {
+ IsBotRunning = _botOrchestrator.Start(Id);
+
+ if (IsBotRunning)
+ EnableButtons(true, false);
+ }
+
+ ///
+ /// Stops the bot instance associated with this view-model.
+ ///
+ public void StopBot()
+ => _botOrchestrator.Stop(Id);
+
+ ///
+ /// Loads the bot update bar.
+ ///
+ /// The bot resolver.
+ /// The update bar.
+ private async static Task LoadUpdateBarAsync(IBotResolver botResolver, DependencyButtonViewModel updateBotBar)
+ {
+ var currentVersion = await botResolver.GetCurrentVersionAsync();
+ updateBotBar.DependencyName = (string.IsNullOrWhiteSpace(currentVersion))
+ ? "Not Installed"
+ : "EllieBot v" + currentVersion;
+
+ var canUpdate = await botResolver.CanUpdateAsync();
+ updateBotBar.Status = (canUpdate is true)
+ ? DependencyStatus.Update
+ : (canUpdate is null)
+ ? DependencyStatus.Install
+ : DependencyStatus.Installed;
+ }
+
+ ///
+ /// Locks or unlocks the settings buttons of this view-model.
+ ///
+ /// Whether the settings buttons should be locked.
+ /// Whether this view-model is currently undergoing an operation.
+ private void EnableButtons(bool lockButtons, bool isIdle)
+ {
+ AreButtonsUnlocked = !lockButtons;
+ IsIdle = isIdle;
+ }
+
+ ///
+ /// Writes stdout and stderr to the fake console and the log writer.
+ ///
+ /// The bot orchestrator.
+ /// The event arguments.
+ private void WriteLog(IBotOrchestrator botOrchestrator, ProcessStdWriteEventArgs eventArgs)
+ {
+ if (eventArgs.Id != Resolver.Id)
+ return;
+
+ _logWriter.TryAdd(eventArgs.Id, eventArgs.Output);
+
+ FakeConsole.Content = (FakeConsole.Content.Length > 100_000)
+ ? FakeConsole.Content[FakeConsole.Content.IndexOf(Environment.NewLine, 60_000)..] + eventArgs.Output + Environment.NewLine
+ : FakeConsole.Content + eventArgs.Output + Environment.NewLine;
+ }
+
+ ///
+ /// Logs when the bot associated with this view-model stops executing.
+ ///
+ /// The bot orchestrator.
+ /// The event arguments.
+ private void LogBotExit(IBotOrchestrator botOrchestrator, BotExitEventArgs eventArgs)
+ {
+ if (eventArgs.Id != Resolver.Id)
+ return;
+
+ var message = Environment.NewLine + ActualBotName + " stopped." + Environment.NewLine;
+
+ _logWriter.TryAdd(Resolver.Id, message);
+ FakeConsole.Content += message;
+ }
+
+ ///
+ /// Reenables the buttons when the bot instance associated with this view-model exits.
+ ///
+ /// The bot orchestrator.
+ /// The event arguments.
+ private void ReenableButtonsOnBotExit(IBotOrchestrator botOrchestrator, BotExitEventArgs eventArgs)
+ {
+ if (eventArgs.Id != Resolver.Id)
+ return;
+
+ IsBotRunning = false;
+ EnableButtons(false, true);
+ }
+
+ ///
+ public void Dispose()
+ {
+ BotAvatar?.Dispose();
+ GC.SuppressFinalize(this);
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/ViewModels/Controls/ConfigViewModel.cs b/EllieHub/ViewModels/Controls/ConfigViewModel.cs
new file mode 100644
index 0000000..1293e60
--- /dev/null
+++ b/EllieHub/ViewModels/Controls/ConfigViewModel.cs
@@ -0,0 +1,239 @@
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Styling;
+using MsBox.Avalonia.Enums;
+using EllieHub.Enums;
+using EllieHub.Services.Abstractions;
+using EllieHub.ViewModels.Abstractions;
+using EllieHub.ViewModels.Windows;
+using EllieHub.Views.Controls;
+using EllieHub.Views.Windows;
+using ReactiveUI;
+using System.Diagnostics;
+
+namespace EllieHub.ViewModels.Controls;
+
+///
+/// The view-model for the application's settings.
+///
+public class ConfigViewModel : ViewModelBase
+{
+ private static readonly string _unixNotice = (Environment.OSVersion.Platform is not PlatformID.Unix)
+ ? string.Empty
+ : Environment.NewLine + "To make the dependencies accessible to your bot instances without this updater, consider installing " +
+ $"them through your package manager or adding the directory \"{AppStatics.AppDepsUri}\" to your PATH environment variable.";
+
+ private readonly IAppConfigManager _appConfigManager;
+ private readonly AboutMeViewModel _aboutMeViewModel;
+ private readonly AppView _mainWindow;
+ private double _maxLogSize;
+ private int _selectedThemeIndex;
+
+ ///
+ /// Defines the maximum size a log file can have, in MB.
+ ///
+ public double MaxLogSize
+ {
+ get => _maxLogSize;
+ private set => this.RaiseAndSetIfChanged(ref _maxLogSize, value);
+ }
+
+ ///
+ /// Defines the index of the theme currently selected.
+ ///
+ public int SelectedThemeIndex
+ {
+ get => _selectedThemeIndex;
+ set
+ {
+ this.RaiseAndSetIfChanged(ref _selectedThemeIndex, value);
+ _ = ChangeThemeAsync((ThemeType)value);
+ }
+ }
+
+ ///
+ /// Contains view-models for buttons that install dependencies for Ellie.
+ ///
+ public IReadOnlyList DependencyButtons { get; }
+
+ ///
+ /// The bar that defines where the bot instances should be stored.
+ ///
+ public UriInputBarViewModel BotsUriBar { get; }
+
+ ///
+ /// The bar that defines where the backup of the bot instances should be stored.
+ ///
+ public UriInputBarViewModel BackupsUriBar { get; }
+
+ ///
+ /// The bar that defines where the backup of the bot instances should be stored.
+ ///
+ public UriInputBarViewModel LogsUriBar { get; }
+
+ ///
+ /// Determines whether the application should minimize to the system tray when closed.
+ ///
+ public bool MinimizeToTray
+ => _appConfigManager.AppConfig.MinimizeToTray;
+
+ ///
+ /// Creates the view-model for the application's settings.
+ ///
+ /// The service that manages the application's settings.
+ /// The main window of the application.
+ /// The bar that defines where the bot instances should be stored.
+ /// The bar that defines where the backups of the bot instances should be stored.
+ /// The bar that defines where the logs of the bot instances should be stored.
+ /// The view-model for the AboutMe window.
+ /// The service that manages ffmpeg on the system.
+ /// The service that manages yt-dlp on the system.
+ public ConfigViewModel(IAppConfigManager appConfigManager, AppView mainWindow, UriInputBarViewModel botsUriBar, UriInputBarViewModel backupsUriBar,
+ UriInputBarViewModel logsUriBar, AboutMeViewModel aboutMeViewModel, IFfmpegResolver ffmpegResolver, IYtdlpResolver ytdlpResolver)
+ {
+ _appConfigManager = appConfigManager;
+ _mainWindow = mainWindow;
+ _maxLogSize = _appConfigManager.AppConfig.LogMaxSizeMb;
+ _selectedThemeIndex = (int)_appConfigManager.AppConfig.Theme;
+ _aboutMeViewModel = aboutMeViewModel;
+
+ BotsUriBar = botsUriBar;
+ BotsUriBar.CurrentUri = appConfigManager.AppConfig.BotsDirectoryUri;
+ BotsUriBar.OnValidUri += async (_, eventArgs) => await appConfigManager.UpdateConfigAsync(x => x.BotsDirectoryUri = eventArgs.NewUri);
+
+ BackupsUriBar = backupsUriBar;
+ BackupsUriBar.CurrentUri = appConfigManager.AppConfig.BotsBackupDirectoryUri;
+ BackupsUriBar.OnValidUri += async (_, eventArgs) => await appConfigManager.UpdateConfigAsync(x => x.BotsBackupDirectoryUri = eventArgs.NewUri);
+
+ LogsUriBar = logsUriBar;
+ LogsUriBar.CurrentUri = appConfigManager.AppConfig.LogsDirectoryUri;
+ LogsUriBar.OnValidUri += async (_, eventArgs) => await appConfigManager.UpdateConfigAsync(x => x.LogsDirectoryUri = eventArgs.NewUri);
+
+ DependencyButtons = new DependencyButtonViewModel[]
+ {
+ new(mainWindow) { DependencyName = ffmpegResolver.DependencyName },
+ new(mainWindow) { DependencyName = ytdlpResolver.DependencyName }
+ };
+
+ _ = InitializeDependencyButtonAsync(DependencyButtons[0], ffmpegResolver);
+ _ = InitializeDependencyButtonAsync(DependencyButtons[1], ytdlpResolver);
+ }
+
+ ///
+ /// Shows the "About Me" window as a dialog window.
+ ///
+ public async ValueTask OpenAboutMeAsync()
+ {
+ // This looks weird, but AboutMeView is rendered useless once it is closed by ShowDialog.
+ var aboutMeView = new AboutMeView()
+ {
+ RequestedThemeVariant = _mainWindow.ActualThemeVariant,
+ ViewModel = _aboutMeViewModel
+ };
+
+ await aboutMeView.ShowDialog(_mainWindow);
+ }
+
+ ///
+ /// Saves the minimize preference to the configuration file.
+ ///
+ public ValueTask ToggleMinimizeToTrayAsync()
+ => _appConfigManager.UpdateConfigAsync(x => x.MinimizeToTray = !x.MinimizeToTray);
+
+ ///
+ /// Sets the value of the button spinner.
+ ///
+ /// The direction the user spun the button.
+ public async Task SpinMaxLogSizeAsync(SpinDirection spinDirection)
+ {
+ if (MaxLogSize is 0.0 && spinDirection is SpinDirection.Decrease)
+ return;
+
+ MaxLogSize = (spinDirection is SpinDirection.Increase)
+ ? Math.Round(Math.Max(0.0, MaxLogSize + 0.1), 2)
+ : Math.Round(Math.Max(0.0, MaxLogSize - 0.1), 2);
+
+ await _appConfigManager.UpdateConfigAsync(x => x.LogMaxSizeMb = MaxLogSize);
+ }
+
+ ///
+ /// Changes the current theme to the specified theme.
+ ///
+ /// The theme to be applied.
+ /// Occurs when has an unimplemented value.
+ private async Task ChangeThemeAsync(ThemeType selectedTheme)
+ {
+ try
+ {
+ // Set the window theme
+ _mainWindow.RequestedThemeVariant = selectedTheme switch
+ {
+ ThemeType.Auto => ThemeVariant.Default,
+ ThemeType.Light => ThemeVariant.Light,
+ ThemeType.Dark => ThemeVariant.Dark,
+ _ => throw new UnreachableException($"No implementation for theme of type {selectedTheme} was provided."),
+ };
+
+ // Update the color of the dependency buttons
+ foreach (var dependencyButton in DependencyButtons)
+ dependencyButton.RecheckButtonColor();
+
+ // Update the application settings
+ await _appConfigManager.UpdateConfigAsync(x => x.Theme = selectedTheme);
+ }
+ catch (Exception ex)
+ {
+ await _mainWindow.ShowDialogWindowAsync("An error occurred when setting a theme:\n" + ex.Message, DialogType.Error, Icon.Error);
+ }
+ }
+
+ ///
+ /// Sets a dependency button to its appropriate state.
+ ///
+ /// The view-model of the dependency button that needs to be initialized.
+ /// The dependency resolver.
+ private async Task InitializeDependencyButtonAsync(DependencyButtonViewModel dependencyButton, IDependencyResolver dependencyResolver)
+ {
+ dependencyButton.Click += async (buttonViewModel, _) => await HandleDependencyAsync(buttonViewModel, dependencyResolver);
+
+ var canUpdate = await dependencyResolver.CanUpdateAsync();
+ dependencyButton.Status = (canUpdate is null)
+ ? DependencyStatus.Install
+ : (canUpdate is true)
+ ? DependencyStatus.Update
+ : DependencyStatus.Installed;
+ }
+
+ ///
+ /// Handles installation and update for a dependency.
+ ///
+ /// The dependency button that was pressed.
+ /// The resolver for a given dependency.
+ ///
+ /// Occurs when returns invalid state.
+ ///
+ private async ValueTask HandleDependencyAsync(DependencyButtonViewModel buttonViewModel, IDependencyResolver dependencyResolver)
+ {
+ var originalStatus = buttonViewModel.Status;
+ buttonViewModel.Status = DependencyStatus.Updating;
+
+ try
+ {
+ var dialogWindowTask = await dependencyResolver.InstallOrUpdateAsync(AppStatics.AppDepsUri) switch
+ {
+ (string oldVer, null) => _mainWindow.ShowDialogWindowAsync($"{dependencyResolver.DependencyName} is already up-to-date (version {oldVer})." + _unixNotice),
+ (null, string newVer) => _mainWindow.ShowDialogWindowAsync($"{dependencyResolver.DependencyName} version {newVer} was successfully installed." + _unixNotice, iconType: Icon.Success),
+ (string oldVer, string newVer) => _mainWindow.ShowDialogWindowAsync($"{dependencyResolver.DependencyName} was successfully updated from version {oldVer} to version {newVer}." + _unixNotice, iconType: Icon.Success),
+ (null, null) => _mainWindow.ShowDialogWindowAsync($"Update of {dependencyResolver.DependencyName} is ongoing.", DialogType.Warning, Icon.Warning)
+ };
+
+ await dialogWindowTask;
+ buttonViewModel.Status = DependencyStatus.Installed;
+ }
+ catch (Exception ex)
+ {
+ await _mainWindow.ShowDialogWindowAsync($"An error occurred while updating {dependencyResolver.DependencyName}:\n{ex.Message}", DialogType.Error, Icon.Error);
+ buttonViewModel.Status = originalStatus;
+ }
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/ViewModels/Controls/DependencyButtonViewModel.cs b/EllieHub/ViewModels/Controls/DependencyButtonViewModel.cs
new file mode 100644
index 0000000..2862676
--- /dev/null
+++ b/EllieHub/ViewModels/Controls/DependencyButtonViewModel.cs
@@ -0,0 +1,113 @@
+using Avalonia.Media.Immutable;
+using EllieHub.Enums;
+using EllieHub.ViewModels.Abstractions;
+using EllieHub.Views.Controls;
+using EllieHub.Views.Windows;
+using ReactiveUI;
+using System.Diagnostics;
+
+namespace EllieHub.ViewModels.Controls;
+
+///
+/// Defines the view-model for a button that installs a dependency for Ellie.
+///
+public class DependencyButtonViewModel : ViewModelBase
+{
+ private string _dependencyName = "DependencyName";
+ private DependencyStatus _status;
+ private bool _isEnabled = false;
+ private ImmutableSolidColorBrush _borderColor;
+ private readonly AppView _appView;
+
+ ///
+ /// Raised when the button associated with this view-model is pressed.
+ ///
+ public event AsyncEventHandler? Click;
+
+ ///
+ /// Determines whether the button associated with this view-model is enabled or not.
+ ///
+ public bool IsEnabled
+ {
+ get => _isEnabled;
+ private set => this.RaiseAndSetIfChanged(ref _isEnabled, value);
+ }
+
+ ///
+ /// The name of the dependency.
+ ///
+ public string DependencyName
+ {
+ get => _dependencyName;
+ set => this.RaiseAndSetIfChanged(ref _dependencyName, value);
+ }
+
+ ///
+ /// The status of this dependency.
+ ///
+ public DependencyStatus Status
+ {
+ get => _status;
+ set
+ {
+ this.RaiseAndSetIfChanged(ref _status, value);
+ BorderColor = GetButtonColor(value);
+ IsEnabled = value is not DependencyStatus.Installed
+ and not DependencyStatus.Updating
+ and not DependencyStatus.Checking;
+ }
+ }
+
+ ///
+ /// The current color of the dependency button.
+ ///
+ public ImmutableSolidColorBrush BorderColor
+ {
+ get => _borderColor;
+ private set => this.RaiseAndSetIfChanged(ref _borderColor, value);
+ }
+
+ ///
+ /// Creates the view-model for a button that installs a dependency for Ellie.
+ ///
+ ///
+ public DependencyButtonViewModel(AppView appView)
+ {
+ _appView = appView;
+ _status = DependencyStatus.Checking;
+ _borderColor = GetButtonColor(Status);
+ }
+
+ ///
+ /// Checks the current status of this button and update its colors appropriately.
+ ///
+ /// The color appropriate to the button's current status.
+ public ImmutableSolidColorBrush RecheckButtonColor()
+ => BorderColor = GetButtonColor(Status);
+
+ ///
+ /// Triggers the event.
+ ///
+ /// This method is run everytime the button associated with this view-model is pressed.
+ protected internal Task RaiseClick()
+ => Click?.Invoke(this, EventArgs.Empty) ?? Task.CompletedTask;
+
+ ///
+ /// Gets the appropriate color according to the specified .
+ ///
+ /// The status of the dependency.
+ /// The color for the status of the dependency.
+ /// Occurs when contains a value that is not implemented.
+ private ImmutableSolidColorBrush GetButtonColor(DependencyStatus status)
+ {
+ return status switch
+ {
+ DependencyStatus.Install => _appView.GetResource(AppResources.DependencyInstall),
+ DependencyStatus.Installed => AppStatics.TransparentColorBrush,
+ DependencyStatus.Updating => _appView.GetResource(AppResources.DependencyUpdate),
+ DependencyStatus.Update => _appView.GetResource(AppResources.DependencyUpdate),
+ DependencyStatus.Checking => AppStatics.TransparentColorBrush,
+ _ => throw new UnreachableException($"Dependency status {status} was not implemented."),
+ };
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/ViewModels/Controls/FakeConsoleViewModel.cs b/EllieHub/ViewModels/Controls/FakeConsoleViewModel.cs
new file mode 100644
index 0000000..e218c5f
--- /dev/null
+++ b/EllieHub/ViewModels/Controls/FakeConsoleViewModel.cs
@@ -0,0 +1,32 @@
+using EllieHub.ViewModels.Abstractions;
+using EllieHub.Views.Controls;
+using ReactiveUI;
+
+namespace EllieHub.ViewModels.Controls;
+
+///
+/// View-model for , the fake console that displays text.
+///
+public class FakeConsoleViewModel : ViewModelBase
+{
+ private string _content = string.Empty;
+ private string _watermark = string.Empty;
+
+ ///
+ /// Text to display.
+ ///
+ public string Content
+ {
+ get => _content;
+ set => this.RaiseAndSetIfChanged(ref _content, value);
+ }
+
+ ///
+ /// Text to display when is empty.
+ ///
+ public string Watermark
+ {
+ get => _watermark;
+ set => this.RaiseAndSetIfChanged(ref _watermark, value);
+ }
+}
\ No newline at end of file
diff --git a/EllieHub/ViewModels/Controls/HomeViewModel.cs b/EllieHub/ViewModels/Controls/HomeViewModel.cs
new file mode 100644
index 0000000..568c18f
--- /dev/null
+++ b/EllieHub/ViewModels/Controls/HomeViewModel.cs
@@ -0,0 +1,18 @@
+using EllieHub.ViewModels.Abstractions;
+using EllieHub.Views.Controls;
+using System.Diagnostics;
+
+namespace EllieHub.ViewModels.Controls;
+
+///
+/// View-model for the home window, with links to Ellie resources.
+///
+public class HomeViewModel : ViewModelBase
+{
+ ///
+ /// Opens the specified URL in the system's default browser.
+ ///
+ /// The URL to open.
+ public void OpenUrl(string url)
+ => Process.Start(new ProcessStartInfo(url) { UseShellExecute = true });
+}
\ No newline at end of file
diff --git a/EllieHub/ViewModels/Controls/LateralBarViewModel.cs b/EllieHub/ViewModels/Controls/LateralBarViewModel.cs
new file mode 100644
index 0000000..21ba419
--- /dev/null
+++ b/EllieHub/ViewModels/Controls/LateralBarViewModel.cs
@@ -0,0 +1,87 @@
+using Avalonia.Controls;
+using EllieHub.Models;
+using EllieHub.Services.Abstractions;
+using EllieHub.ViewModels.Abstractions;
+using EllieHub.Views.Controls;
+using ReactiveUI;
+using System.Collections.ObjectModel;
+using System.Reactive.Disposables;
+
+namespace EllieHub.ViewModels.Controls;
+
+///
+/// View-model for , the lateral bar with home, bot, and configuration buttons.
+///
+public class LateralBarViewModel : ViewModelBase
+{
+ ///
+ /// Collection of buttons for bot instances.
+ ///
+ public ObservableCollection