Compare commits

..

No commits in common. "main" and "1.0.1.0" have entirely different histories.

107 changed files with 1157 additions and 2289 deletions
.editorconfig
.forgejo
.gitignoreEllieHub.sln
EllieHub
App.axamlApp.axaml.cs
Common
DesignData
EllieHub.csproj
Extensions
Features
AppWindow/Models
BotConfig/Models/Api/Toastielab
Home
Models/Api/Toastielab
Services
Models
Resources
Services
Styles
ViewLocator.cs
ViewModels
Views

View file

@ -1,4 +1,3 @@
root = true
[*]
charset = utf-8
@ -9,66 +8,14 @@ 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
dotnet_style_coalesce_expression = true:suggestion
dotnet_style_null_propagation = true:suggestion
dotnet_style_object_initializer = true:suggestion
dotnet_style_collection_initializer = true:suggestion
dotnet_style_prefer_simplified_boolean_expressions = true:suggestion
dotnet_style_explicit_tuple_names = true:suggestion
dotnet_style_prefer_inferred_tuple_names = true:suggestion
dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion
dotnet_style_prefer_compound_assignment = true:suggestion
dotnet_style_prefer_simplified_interpolation = true:suggestion
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
dotnet_style_namespace_match_folder = true:suggestion
dotnet_style_readonly_field = true:warning
dotnet_style_predefined_type_for_locals_parameters_members = true:warning
dotnet_style_predefined_type_for_member_access = true:warning
dotnet_style_allow_multiple_blank_lines_experimental = false:silent
dotnet_style_allow_statement_immediately_after_block_experimental = false:silent
dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent
dotnet_style_qualification_for_field = false:silent
dotnet_style_qualification_for_property = false:silent
dotnet_style_qualification_for_method = false:silent
dotnet_style_qualification_for_event = false:silent
dotnet_diagnostic.CA1310.severity = warning
dotnet_diagnostic.CA1509.severity = error
dotnet_diagnostic.CA1000.severity = suggestion
dotnet_diagnostic.CA1001.severity = error
dotnet_diagnostic.CA1005.severity = none
dotnet_diagnostic.CA1008.severity = suggestion
dotnet_diagnostic.CA1010.severity = suggestion
dotnet_diagnostic.CA1031.severity = silent
dotnet_diagnostic.CA1050.severity = error
dotnet_diagnostic.CA1051.severity = error
dotnet_diagnostic.CA1069.severity = suggestion
dotnet_diagnostic.CA1724.severity = error
dotnet_diagnostic.CA1826.severity = warning
dotnet_diagnostic.CA1827.severity = warning
dotnet_diagnostic.CA1828.severity = warning
dotnet_diagnostic.CA1829.severity = warning
dotnet_diagnostic.CA1830.severity = warning
dotnet_diagnostic.CA1832.severity = warning
dotnet_diagnostic.CA1833.severity = warning
dotnet_diagnostic.CA1842.severity = warning
dotnet_diagnostic.CA1843.severity = warning
dotnet_diagnostic.CA1836.severity = none
dotnet_diagnostic.CA1839.severity = warning
dotnet_diagnostic.CA1840.severity = warning
dotnet_diagnostic.CA1846.severity = warning
dotnet_diagnostic.CA1848.severity = suggestion
dotnet_diagnostic.CA1852.severity = suggestion
dotnet_diagnostic.CA2012.severity = warning
dotnet_diagnostic.CA2019.severity = warning
dotnet_diagnostic.CA2211.severity = warning
dotnet_diagnostic.CA1822.severity = suggestion
dotnet_diagnostic.CA1725.severity = suggestion
[*.cs]
#### .NET Coding Conventions ####
@ -132,49 +79,49 @@ dotnet_style_allow_statement_immediately_after_block_experimental = false
#### C# Coding Conventions ####
# var preferences
csharp_style_var_elsewhere = true:warning
csharp_style_var_for_built_in_types = true:warning
csharp_style_var_when_type_is_apparent = true:warning
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 = when_on_single_line: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:warning
csharp_style_pattern_matching_over_is_with_cast_check = true:warning
csharp_style_prefer_not_pattern = true:warning
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:suggestion
csharp_style_prefer_switch_expression = true
# Null-checking preferences
csharp_style_conditional_delegate_call = true:warning
csharp_style_conditional_delegate_call = true:error
# Modifier preferences
csharp_prefer_static_local_function = true:suggestion
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:suggestion
csharp_prefer_simple_using_statement = true
# Expression-level preferences
csharp_prefer_simple_default_expression = true:suggestion
csharp_style_deconstructed_variable_declaration = true:suggestion
csharp_style_implicit_object_creation_when_type_is_apparent = true:warning
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:suggestion
csharp_style_prefer_range_operator = true:suggestion
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:silent
csharp_style_unused_value_expression_statement_preference = discard_variable
# 'using' directive preferences
csharp_using_directive_placement = outside_namespace:error
@ -183,9 +130,9 @@ csharp_using_directive_placement = outside_namespace:error
csharp_style_namespace_declarations = file_scoped:error
# New line preferences
csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent
csharp_style_allow_blank_lines_between_consecutive_braces_experimental = false:silent
csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent
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 ####
@ -242,40 +189,40 @@ csharp_preserve_single_line_statements = true
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 = upper_camel_case_style
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 = upper_camel_case_style
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 = i_upper_camel_case_style
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 = upper_camel_case_style
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 = upper_camel_case_style
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 = upper_camel_case_style
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 = upper_camel_case_style
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 = warning
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
@ -287,17 +234,17 @@ dotnet_naming_rule.private_field_should_be_begins_with_underscore.style = begins
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 = upper_camel_case_style
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 = lower_camel_case_style_1
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 = upper_camel_case_style
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
@ -341,7 +288,7 @@ 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 = begins_with_underscore
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
@ -351,12 +298,12 @@ dotnet_naming_rule.private_instance_fields_rule.symbols = private_instance_field
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 = begins_with_underscore
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 = begins_with_underscore
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
@ -371,7 +318,7 @@ 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 = lower_camel_case_style_1
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
@ -535,91 +482,22 @@ dotnet_naming_symbols.type_parameters_symbols.applicable_accessibilities = *
dotnet_naming_symbols.type_parameters_symbols.applicable_kinds = type_parameter
#### Style Rules ####
#### Visual Studio Specific Rules ####
csharp_style_prefer_method_group_conversion = true:silent
csharp_style_prefer_top_level_statements = false:suggestion
csharp_style_prefer_primary_constructors = false:warning
csharp_style_prefer_null_check_over_type_check = true:suggestion
csharp_style_prefer_local_over_anonymous_function = true:suggestion
csharp_style_prefer_tuple_swap = true:suggestion
csharp_style_prefer_utf8_string_literals = true:suggestion
csharp_style_prefer_readonly_struct = true:suggestion
csharp_style_prefer_readonly_struct_member = true:suggestion
csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent
csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent
csharp_style_prefer_extended_property_pattern = true:warning
dotnet_diagnostic.CA1311.severity = suggestion
dotnet_diagnostic.CA1507.severity = warning
dotnet_diagnostic.IDE0047.severity = none
dotnet_diagnostic.IDE0052.severity = warning
dotnet_diagnostic.IDE0051.severity = warning
dotnet_diagnostic.IDE0076.severity = error
dotnet_diagnostic.IDE0077.severity = error
dotnet_diagnostic.IDE0043.severity = warning
dotnet_diagnostic.CA1070.severity = error
dotnet_diagnostic.CA1200.severity = suggestion
dotnet_diagnostic.CA1805.severity = suggestion
dotnet_diagnostic.CA1825.severity = warning
dotnet_diagnostic.IDE0009.severity = suggestion
dotnet_diagnostic.IDE0016.severity = suggestion
dotnet_diagnostic.IDE0017.severity = suggestion
dotnet_diagnostic.IDE0018.severity = suggestion
dotnet_diagnostic.IDE0019.severity = suggestion
dotnet_diagnostic.IDE0020.severity = suggestion
dotnet_diagnostic.IDE0028.severity = suggestion
dotnet_diagnostic.IDE0029.severity = suggestion
dotnet_diagnostic.IDE0030.severity = suggestion
dotnet_diagnostic.IDE0031.severity = suggestion
dotnet_diagnostic.IDE0032.severity = suggestion
dotnet_diagnostic.IDE0034.severity = suggestion
dotnet_diagnostic.IDE0039.severity = suggestion
dotnet_diagnostic.IDE0040.severity = warning
dotnet_diagnostic.IDE0041.severity = suggestion
dotnet_diagnostic.IDE0044.severity = suggestion
dotnet_diagnostic.IDE0045.severity = suggestion
dotnet_diagnostic.IDE0046.severity = suggestion
dotnet_diagnostic.IDE0048.severity = suggestion
dotnet_diagnostic.IDE0053.severity = suggestion
dotnet_diagnostic.IDE0059.severity = warning
dotnet_diagnostic.IDE0060.severity = warning
dotnet_diagnostic.IDE0062.severity = suggestion
dotnet_diagnostic.IDE0063.severity = suggestion
dotnet_diagnostic.IDE0071.severity = suggestion
dotnet_diagnostic.IDE0082.severity = warning
dotnet_diagnostic.IDE0080.severity = suggestion
dotnet_diagnostic.IDE0130.severity = warning
dotnet_diagnostic.IDE0120.severity = suggestion
dotnet_diagnostic.IDE0100.severity = warning
dotnet_diagnostic.IDE0150.severity = suggestion
dotnet_diagnostic.IDE0161.severity = warning
dotnet_diagnostic.IDE0170.severity = suggestion
dotnet_diagnostic.IDE0180.severity = suggestion
dotnet_diagnostic.IDE0200.severity = suggestion
dotnet_diagnostic.IDE0211.severity = suggestion
dotnet_diagnostic.IDE0230.severity = suggestion
dotnet_diagnostic.IDE0240.severity = suggestion
dotnet_diagnostic.IDE0241.severity = suggestion
dotnet_diagnostic.IDE0251.severity = suggestion
dotnet_diagnostic.IDE0250.severity = suggestion
dotnet_diagnostic.IDE0260.severity = suggestion
dotnet_diagnostic.IDE0270.severity = suggestion
dotnet_diagnostic.IDE0280.severity = warning
dotnet_diagnostic.IDE1005.severity = suggestion
dotnet_diagnostic.IDE1006.severity = warning
# CA1822: Mark members as static
dotnet_diagnostic.CA1822.severity = none
# IDE0004: Cast is redundant
dotnet_diagnostic.IDE0004.severity = error
# IDE0011: Add braces to 'if'/'else' statement
dotnet_diagnostic.IDE0011.severity = none
# IDE0058: Expression value is never used
dotnet_diagnostic.IDE0058.severity = none
# Use primary constructor
dotnet_diagnostic.IDE0290.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 ####
@ -703,16 +581,14 @@ 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_for_multiline
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_for_multiline
resharper_braces_for_using = 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_builtin_type_apply_to_native_integer = true
resharper_csharp_empty_block_style = together_same_line
resharper_break_template_declaration = line_break
resharper_can_use_global_alias = false
resharper_configure_await_analysis_mode = disabled
@ -810,7 +686,7 @@ 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 =
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
@ -911,7 +787,7 @@ 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 = 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
@ -1062,7 +938,6 @@ 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_csharp_allow_alias = false
resharper_special_else_if_treatment = true
resharper_static_members_qualify_members = none
resharper_static_members_qualify_with = declared_type
@ -1168,7 +1043,7 @@ 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 = none
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
@ -1283,7 +1158,7 @@ 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 = none
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
@ -1402,8 +1277,8 @@ 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 = hint
resharper_member_can_be_private_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
@ -1531,7 +1406,7 @@ 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 = none
resharper_redundant_base_qualifier_highlighting = warning
resharper_redundant_blank_lines_highlighting = none
resharper_redundant_bool_compare_highlighting = warning
resharper_redundant_case_label_highlighting = warning
@ -1541,7 +1416,7 @@ 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 = 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
@ -1867,17 +1742,3 @@ resharper_void_method_with_must_use_return_value_attribute_highlighting = warnin
resharper_with_expression_instead_of_initializer_highlighting = suggestion
resharper_wrong_indent_size_highlighting = none
resharper_xunit_xunit_test_with_console_output_highlighting = warning
resharper_arrange_constructor_or_destructor_body_highlighting = hint
resharper_arrange_local_function_body_highlighting = hint
resharper_arrange_method_or_operator_body_highlighting = hint
resharper_arrange_null_checking_pattern_highlighting = suggestion
resharper_enforce_do_while_statement_braces_highlighting = warning
resharper_enforce_fixed_statement_braces_highlighting = warning
resharper_enforce_foreach_statement_braces_highlighting = none
resharper_enforce_for_statement_braces_highlighting = none
resharper_enforce_if_statement_braces_highlighting = none
resharper_enforce_lock_statement_braces_highlighting = warning
resharper_enforce_while_statement_braces_highlighting = none
resharper_remove_redundant_braces_highlighting = hint
resharper_suggest_discard_declaration_var_style_highlighting = warning
resharper_prefer_concrete_value_over_default_highlighting = hint

View file

@ -1,56 +0,0 @@
name: Bug Report
description: File a bug report.
labels: ["bug"]
body:
- type: dropdown
attributes:
label: Which version of EllieHub are you using?
multiple: false
options:
- "Windows x64"
- "Windows arm64"
- "Linux x64"
- "Linux arm64"
- "MacOS x64"
- "MacOS arm64"
- "I compiled from source"
validations:
required: true
- type: textarea
attributes:
label: "Description"
description: "Give a concise description of the problem"
validations:
required: true
- type: textarea
attributes:
label: "Reproduction Steps"
description: "Enumerate the steps needed to reproduce the behavior"
value: "1. Go to '...'\n2. Click on '....'\n3. Scroll down to '....'\n4. See error"
validations:
required: true
- type: textarea
attributes:
label: "Expected Behavior"
description: "Describe what you expected to happen"
validations:
required: true
- type: textarea
attributes:
label: "Actual Behavior"
description: "Describe what actually happened"
validations:
required: false
- type: textarea
attributes:
label: "Screenshots"
description: "If applicable, add screenshots to help explain your problem"
validations:
required: false
- type: textarea
attributes:
label: "Notes"
description: "Add any other context about the problem here"
placeholder: "eg. OS version, etc"
validations:
required: false

View file

@ -1,30 +0,0 @@
name: "Feature Request"
description: Suggest an idea for this project.
labels: ["enhancement"]
body:
- type: "textarea"
attributes:
label: "Is your feature request related to a problem?"
description: "If so, describe what the problem is"
placeholder: "eg. I'm always frustrated when [...]"
validations:
required: false
- type: "textarea"
attributes:
label: "Describe the feature you'd like"
description: "Describe what you want to happen"
placeholder: "eg. The application should do [...] then [...]"
validations:
required: true
- type: "textarea"
attributes:
label: "What alternatives have you considered?"
description: "Feel free to mention similar applications if you feel this will get your point across better"
validations:
required: false
- type: "textarea"
attributes:
label: "Additional context"
description: "Add any other context or screenshots about the feature request here"
validations:
required: false

3
.gitignore vendored
View file

@ -481,6 +481,3 @@ $RECYCLE.BIN/
# Built files
build/*
zips/*
# Scripts
release.ps1

View file

@ -5,11 +5,6 @@ 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
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{2A553F1B-5A2C-43D6-A145-F219530326D3}"
ProjectSection(SolutionItems) = preProject
release.ps1 = release.ps1
EndProjectSection
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU

View file

@ -14,9 +14,9 @@
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ResourceInclude Source="/Avalonia/Resources/Colors.axaml" />
<ResourceInclude Source="/Avalonia/Resources/Fonts.axaml" />
<ResourceInclude Source="/Avalonia/Resources/Images.axaml" />
<ResourceInclude Source="/Resources/Colors.axaml" />
<ResourceInclude Source="/Resources/Fonts.axaml" />
<ResourceInclude Source="/Resources/Images.axaml" />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
@ -24,12 +24,12 @@
<!--Styles-->
<!--Think of them like CSS classes-->
<Application.Styles>
<StyleInclude Source="/Avalonia/Styles/EllieStyles.axaml" />
<StyleInclude Source="/Styles/EllieStyles.axaml" />
<!--Generated Here: https://theme.xaml.live/-->
<FluentTheme>
<FluentTheme.Palettes>
<ColorPaletteResources x:Key="Light" Accent="#ff0066ff" AltHigh="White" AltLow="White" AltMedium="White" AltMediumHigh="White" AltMediumLow="White" BaseHigh="Black" BaseLow="#ffcccccc" BaseMedium="#ff898989" BaseMediumHigh="#ff5d5d5d" BaseMediumLow="#ff737373" ChromeAltLow="#ff5d5d5d" ChromeBlackHigh="Black" ChromeBlackLow="#ffcccccc" ChromeBlackMedium="#ff5d5d5d" ChromeBlackMediumLow="#ff898989" ChromeDisabledHigh="#ffcccccc" ChromeDisabledLow="#ff898989" ChromeGray="#ff737373" ChromeHigh="#ffcccccc" ChromeLow="#ffececec" ChromeMedium="#ffe6e6e6" ChromeMediumLow="#ffececec" ChromeWhite="White" ListLow="#ffe6e6e6" ListMedium="#ffcccccc" RegionColor="White" />
<ColorPaletteResources x:Key="Dark" Accent="#ff005dd9" AltHigh="Black" AltLow="Black" AltMedium="Black" AltMediumHigh="Black" AltMediumLow="Black" BaseHigh="White" BaseLow="#ff333333" BaseMedium="#ff9a9a9a" BaseMediumHigh="#ffb4b4b4" BaseMediumLow="#ff676767" ChromeAltLow="#ffb4b4b4" ChromeBlackHigh="Black" ChromeBlackLow="#ffb4b4b4" ChromeBlackMedium="Black" ChromeBlackMediumLow="Black" ChromeDisabledHigh="#ff333333" ChromeDisabledLow="#ff9a9a9a" ChromeGray="Gray" ChromeHigh="Gray" ChromeLow="#ff151515" ChromeMedium="#ff1d1d1d" ChromeMediumLow="#ff2c2c2c" ChromeWhite="White" ListLow="#ff1d1d1d" ListMedium="#ff333333" RegionColor="Black" />
<ColorPaletteResources x:Key="Light" Accent="#ff0073cf" AltHigh="White" AltLow="White" AltMedium="White" AltMediumHigh="White" AltMediumLow="White" BaseHigh="Black" BaseLow="#6fcccccc" BaseMedium="#c5898989" BaseMediumHigh="#ff5d5d5d" BaseMediumLow="#e2737373" ChromeAltLow="#ff5d5d5d" ChromeBlackHigh="Black" ChromeBlackLow="#6fcccccc" ChromeBlackMedium="#ff5d5d5d" ChromeBlackMediumLow="#c5898989" ChromeDisabledHigh="#6fcccccc" ChromeDisabledLow="#c5898989" ChromeGray="#e2737373" ChromeHigh="#6fcccccc" ChromeLow="#ffececec" ChromeMedium="#e2e6e6e6" ChromeMediumLow="#ffececec" ChromeWhite="White" ListLow="#e2e6e6e6" ListMedium="#6fcccccc" RegionColor="White" />
<ColorPaletteResources x:Key="Dark" Accent="#ff0073cf" AltHigh="Black" AltLow="Black" AltMedium="Black" AltMediumHigh="Black" AltMediumLow="Black" BaseHigh="White" BaseLow="#ff333333" BaseMedium="#ff9a9a9a" BaseMediumHigh="#ffb4b4b4" BaseMediumLow="#ff676767" ChromeAltLow="#ffb4b4b4" ChromeBlackHigh="Black" ChromeBlackLow="#ffb4b4b4" ChromeBlackMedium="Black" ChromeBlackMediumLow="Black" ChromeDisabledHigh="#ff333333" ChromeDisabledLow="#ff9a9a9a" ChromeGray="Gray" ChromeHigh="Gray" ChromeLow="#ff151515" ChromeMedium="#ff1d1d1d" ChromeMediumLow="#ff2c2c2c" ChromeWhite="White" ListLow="#ff1d1d1d" ListMedium="#ff333333" RegionColor="#ff181818" />
</FluentTheme.Palettes>
</FluentTheme>
</Application.Styles>

View file

@ -3,7 +3,7 @@ using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using Microsoft.Extensions.DependencyInjection;
using EllieHub.Features.AppWindow.Views.Windows;
using EllieHub.Views.Windows;
using System.Reflection;
namespace EllieHub;
@ -20,7 +20,7 @@ public partial class App : Application
/// </summary>
public IServiceProvider Services { get; } = new ServiceCollection()
.RegisterViewsAndViewModels(Assembly.GetExecutingAssembly())
.RegisterAppServices()
.RegisterServices()
.BuildServiceProvider(true);
/// <inheritdoc />

View file

@ -14,9 +14,4 @@ public static class AppConstants
/// The name for an <see cref="HttpClient"/> that does not automatically follow redirect responses.
/// </summary>
public const string NoRedirectClient = "NoRedirect";
/// <summary>
/// The name for an <see cref="HttpClient"/> that makes calls to the Toastielab API.
/// </summary>
public const string ToastielabClient = "NoRedirect";
}

View file

@ -1,7 +1,6 @@
using Avalonia.Media;
using Avalonia.Media.Immutable;
using Avalonia.Platform.Storage;
using System.Reflection;
using System.Text.RegularExpressions;
namespace EllieHub.Common;
@ -15,35 +14,35 @@ public static partial class AppStatics
/// Defines the default location where the updater configuration and bot instances are stored.
/// </summary>
#if DEBUG
public static string AppDefaultConfigDirectoryUri { get; } = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.Personal), "EllieHubDebug");
public static string AppDefaultConfigDirectoryUri { get; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.Personal), "EllieHubDebug");
#else
public static string AppDefaultConfigDirectoryUri { get; } = Path.Join(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "EllieHub");
public static string AppDefaultConfigDirectoryUri { get; } = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "EllieHub");
#endif
/// <summary>
/// Defines the default location where the bot instances are stored.
/// </summary>
public static string AppDefaultBotDirectoryUri { get; } = Path.Join(AppDefaultConfigDirectoryUri, "Bots");
public static string AppDefaultBotDirectoryUri { get; } = Path.Combine(AppDefaultConfigDirectoryUri, "Bots");
/// <summary>
/// Defines the default location where the backups of bot instances are stored.
/// </summary>
public static string AppDefaultBotBackupDirectoryUri { get; } = Path.Join(AppDefaultConfigDirectoryUri, "Backups");
public static string AppDefaultBotBackupDirectoryUri { get; } = Path.Combine(AppDefaultConfigDirectoryUri, "Backups");
/// <summary>
/// Defines the default location where the logs of bot instances are stored.
/// </summary>
public static string AppDefaultLogDirectoryUri { get; } = Path.Join(AppDefaultConfigDirectoryUri, "Logs");
public static string AppDefaultLogDirectoryUri { get; } = Path.Combine(AppDefaultConfigDirectoryUri, "Logs");
/// <summary>
/// Defines the location of the application's configuration file.
/// </summary>
public static string AppConfigUri { get; } = Path.Join(AppDefaultConfigDirectoryUri, "config.json");
public static string AppConfigUri { get; } = Path.Combine(AppDefaultConfigDirectoryUri, "config.json");
/// <summary>
/// Defines the location of the application's dependencies.
/// </summary>
public static string AppDepsUri { get; } = Path.Join(AppDefaultConfigDirectoryUri, "Dependencies");
public static string AppDepsUri { get; } = Path.Combine(AppDefaultConfigDirectoryUri, "Dependencies");
/// <summary>
/// Defines a transparent color brush.
@ -56,19 +55,13 @@ public static partial class AppStatics
public static FilePickerOpenOptions ImageFilePickerOptions { get; } = new()
{
AllowMultiple = false,
FileTypeFilter =
[
new("Image") { Patterns = ["*.png", "*.jpg", "*.jpeg", "*.gif", "*.webp"]},
new("All") { Patterns = ["*.*"]}
]
FileTypeFilter = new FilePickerFileType[]
{
new("Image") { Patterns = new[] { "*.png", "*.jpg", "*.jpeg", "*.gif", "*.webp" } },
new("All") { Patterns = new[] { "*.*" } }
}
};
/// <summary>
/// The version of this application.
/// </summary>
public static string AppVersion { get; } = Assembly.GetExecutingAssembly().GetName().Version?.ToString()
?? throw new InvalidOperationException("Version is missing from application assembly.");
/// <summary>
/// Matches the version of Ffmpeg from its CLI output.
/// </summary>

View file

@ -1,5 +1,8 @@
using Avalonia.Platform;
using SkiaSharp;
using System.ComponentModel;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
namespace EllieHub.Common;
@ -8,30 +11,205 @@ namespace EllieHub.Common;
/// </summary>
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;
/// <summary>
/// Loads an image embedded with this application.
/// Loads an image embeded with this application.
/// </summary>
/// <param name="uri">An uri that starts with "avares://"</param>
/// <remarks>Valid uris must start with "avares://".</remarks>
/// <returns>The embedded image or the default bot avatar placeholder.</returns>
/// <exception cref="FileNotFoundException">Occurs when the embedded resource does not exist.</exception>
public static SKBitmap LoadEmbeddedImage(string? uri = default)
/// <returns>The embeded image or the default bot avatar placeholder.</returns>
/// <exception cref="FileNotFoundException">Occurs when the embeded resource does not exist.</exception>
public static SKBitmap LoadEmbededImage(string? uri = default)
{
return (string.IsNullOrWhiteSpace(uri) || !uri.StartsWith("avares://", StringComparison.Ordinal))
? SKBitmap.Decode(AssetLoader.Open(new(AppConstants.BotAvatarUri)))
: SKBitmap.Decode(AssetLoader.Open(new(uri)));
return (string.IsNullOrWhiteSpace(uri) || !uri.StartsWith("avares://"))
? SKBitmap.Decode(AssetLoader.Open(new Uri(AppConstants.BotAvatarUri)))
: SKBitmap.Decode(AssetLoader.Open(new Uri(uri)));
}
/// <summary>
/// Loads the image at the specified location or the bot avatar placeholder if it was not found.
/// </summary>
/// <param name="imagePath">The absolute path to the image file or <see langword="null"/> to get the avatar placeholder.</param>
/// <remarks>This fallsback to <see cref="LoadEmbeddedImage(string?)"/> if <paramref name="imagePath"/> doesn't point to a valid image file.</remarks>
/// <param name="uri">The absolute path to the image file or <see langword="null"/> to get the avatar placeholder.</param>
/// <remarks>This fallsback to <see cref="LoadEmbededImage(string?)"/> if <paramref name="uri"/> doesn't point to a valid image file.</remarks>
/// <returns>The requested image or the default bot avatar placeholder.</returns>
public static SKBitmap LoadLocalImage(string? imagePath)
public static SKBitmap LoadLocalImage(string? uri = default)
{
return (File.Exists(imagePath))
? SKBitmap.Decode(imagePath)
: LoadEmbeddedImage(imagePath);
return (File.Exists(uri))
? SKBitmap.Decode(uri)
: LoadEmbededImage(uri);
}
/// <summary>
/// Safely casts an <see cref="object"/> to a <typeparamref name="T"/>.
/// </summary>
/// <typeparam name="T">The type to cast to.</typeparam>
/// <param name="obj">The object to be cast.</param>
/// <param name="castObject">The cast object, or <see langword="null"/> is casting failed.</param>
/// <returns><see langword="true"/> if the object was successfully cast, <see langword="false"/> otherwise.</returns>
public static bool TryCastTo<T>(object? obj, [MaybeNullWhen(false)] out T castObject)
{
if (obj is T result)
{
castObject = result;
return true;
}
castObject = default;
return false;
}
/// <summary>
/// Starts the specified program in the background.
/// </summary>
/// <param name="program">
/// The name of the program in the PATH environment variable,
/// or the absolute path to its executable.
/// </param>
/// <param name="arguments">The arguments to the program.</param>
/// <returns>The process of the specified program.</returns>
/// <exception cref="ArgumentException" />
/// <exception cref="ArgumentNullException" />
/// <exception cref="Win32Exception">Occurs when <paramref name="program"/> does not exist.</exception>
/// <exception cref="InvalidOperationException">Occurs when the process fails to execute.</exception>
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}");
}
/// <summary>
/// Checks if a program exists.
/// </summary>
/// <param name="programName">The name of the program.</param>
/// <param name="cToken">The cancellation token.</param>
/// <returns><see langword="true"/> if the program exists, <see langword="false"/> otherwise.</returns>
/// <exception cref="ArgumentException" />
/// <exception cref="ArgumentNullException" />
public static async ValueTask<bool> 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));
}
/// <summary>
/// Safely deletes a file.
/// </summary>
/// <param name="fileUri">The absolute path to the file.</param>
/// <returns><see langword="true"/> if the file was deleted, <see langword="false"/> otherwise.</returns>
/// <exception cref="ArgumentException" />
/// <exception cref="ArgumentNullException" />
/// <exception cref="IOException" />
/// <exception cref="NotSupportedException" />
/// <exception cref="PathTooLongException" />
/// <exception cref="UnauthorizedAccessException" />
public static bool TryDeleteFile(string fileUri)
{
ArgumentException.ThrowIfNullOrEmpty(fileUri, nameof(fileUri));
if (!File.Exists(fileUri))
return false;
File.Delete(fileUri);
return true;
}
/// <summary>
/// Safely deletes a directory.
/// </summary>
/// <param name="directoryUri">The absolute path to the directory.</param>
/// <returns><see langword="true"/> if the directory was deleted, <see langword="false"/> otherwise.</returns>
/// <exception cref="ArgumentException" />
/// <exception cref="ArgumentNullException" />
/// <exception cref="IOException" />
/// <exception cref="DirectoryNotFoundException" />
/// <exception cref="PathTooLongException" />
/// <exception cref="UnauthorizedAccessException" />
public static bool TryDeleteDirectory(string directoryUri)
{
ArgumentException.ThrowIfNullOrEmpty(directoryUri, nameof(directoryUri));
if (!Directory.Exists(directoryUri))
return false;
Directory.Delete(directoryUri, true);
return true;
}
/// <summary>
/// Checks if this application can write to <paramref name="directoryUri"/>.
/// </summary>
/// <param name="directoryUri">The absolute path to a directory.</param>
/// <returns><see langword="true"/> if writing is allowed, <see langword="false"/> otherwise.</returns>
/// <exception cref="PathTooLongException" />
/// <exception cref="DirectoryNotFoundException" />
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);
}
}
/// <summary>
/// Adds a directory path to the PATH environment variable.
/// </summary>
/// <param name="directoryUri">The absolute path to a directory.</param>
/// <remarks>
/// On Windows, this needs to be called once and the dependencies will be available for the user forever. <br />
/// 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.
/// </remarks>
/// <returns><see langword="true"/> if <paramref name="directoryUri"/> got successfully added to the PATH envar, <see langword="false"/> otherwise.</returns>
/// <exception cref="ArgumentException" />
/// <exception cref="ArgumentNullException" />
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;
}
}

View file

@ -8,12 +8,12 @@ public static class WindowConstants
/// <summary>
/// Defines the default width of the window.
/// </summary>
public const string DefaultWindowWidth = "900";
public const string DefaultWindowWidth = "885";
/// <summary>
/// Defines the default height of the window.
/// </summary>
public const string DefaultWindowHeight = "575";
public const string DefaultWindowHeight = "570";
/// <summary>
/// Defines the minimum height of the window.
@ -33,5 +33,5 @@ public static class WindowConstants
/// <summary>
/// Defines the message that should be shown when a view's parameterless constructor should not be used.
/// </summary>
public const string DesignerCtorWarning = "This constructor exists to satisfy Avalonia's previewer. Please, use the parameterized constructor instead.";
public const string DesignerCtorWarning = "This constructor exists to satisfy Avalonia's designer. Please, use the parameterized constructor instead.";
}

View file

@ -1,7 +1,7 @@
using Avalonia;
using Avalonia.Controls;
namespace EllieHub.Avalonia.DesignData.Common;
namespace EllieHub.DesignData.Common;
/// <summary>
/// Defines objects useful at design-time.

View file

@ -1,14 +1,11 @@
using Microsoft.Extensions.DependencyInjection;
using EllieHub.Avalonia.DesignData.Common;
using EllieHub.Features.AppConfig.Services.Mocks;
using EllieHub.Features.AppWindow.ViewModels;
using EllieHub.Features.AppWindow.Views.Windows;
using EllieHub.Features.BotConfig.Services.Abstractions;
using EllieHub.Features.BotConfig.Services.Mocks;
using EllieHub.Features.BotConfig.ViewModels;
using EllieHub.Features.Common.ViewModels;
using EllieHub.DesignData.Common;
using EllieHub.Services.Abstractions;
using EllieHub.Services.Mocks;
using EllieHub.ViewModels.Controls;
using EllieHub.Views.Windows;
namespace EllieHub.Avalonia.DesignData.Controls;
namespace EllieHub.DesignData.Controls;
/// <summary>
/// Mock view-model for <see cref="BotConfigViewModel"/>.

View file

@ -1,12 +1,12 @@
using Microsoft.Extensions.DependencyInjection;
using EllieHub.Avalonia.DesignData.Common;
using EllieHub.Features.AppConfig.Services.Abstractions;
using EllieHub.Features.AppConfig.Services.Mocks;
using EllieHub.Features.AppConfig.ViewModels;
using EllieHub.Features.AppWindow.Views.Windows;
using EllieHub.Features.Common.ViewModels;
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.Avalonia.DesignData.Controls;
namespace EllieHub.DesignData.Controls;
/// <summary>
/// Mock view-model for <see cref="ConfigViewModel"/>.

View file

@ -1,9 +1,9 @@
using Microsoft.Extensions.DependencyInjection;
using EllieHub.Avalonia.DesignData.Common;
using EllieHub.Features.AppWindow.Views.Windows;
using EllieHub.Features.Common.ViewModels;
using EllieHub.DesignData.Common;
using EllieHub.ViewModels.Controls;
using EllieHub.Views.Windows;
namespace EllieHub.Avalonia.DesignData.Controls;
namespace EllieHub.DesignData.Controls;
/// <summary>
/// Mock view-model for <see cref="DependencyButtonViewModel"/>.

View file

@ -1,6 +1,6 @@
using EllieHub.Features.BotConfig.ViewModels;
using EllieHub.ViewModels.Controls;
namespace EllieHub.Avalonia.DesignData.Controls;
namespace EllieHub.DesignData.Controls;
/// <summary>
/// Mock view-model for <see cref="FakeConsoleViewModel"/>.
@ -10,6 +10,6 @@ public sealed class DesignFakeConsoleViewModel : FakeConsoleViewModel
/// <summary>
/// Creates a mock <see cref="FakeConsoleViewModel"/> to be used at design-time.
/// </summary>
public DesignFakeConsoleViewModel()
public DesignFakeConsoleViewModel() : base()
=> Watermark = "Sample watermark.";
}

View file

@ -1,6 +1,6 @@
using EllieHub.Features.Home.ViewModels;
using EllieHub.ViewModels.Controls;
namespace EllieHub.Avalonia.DesignData.Controls;
namespace EllieHub.DesignData.Controls;
/// <summary>
/// Mock view-model for <see cref="HomeViewModel"/>.

View file

@ -1,9 +1,9 @@
using Microsoft.Extensions.DependencyInjection;
using EllieHub.Avalonia.DesignData.Common;
using EllieHub.Features.AppConfig.Services.Mocks;
using EllieHub.Features.AppWindow.ViewModels;
using EllieHub.DesignData.Common;
using EllieHub.Services.Mocks;
using EllieHub.ViewModels.Controls;
namespace EllieHub.Avalonia.DesignData.Controls;
namespace EllieHub.DesignData.Controls;
/// <summary>
/// Mock view-model for <see cref="LateralBarViewModel"/>.

View file

@ -1,9 +1,9 @@
using Avalonia.Platform.Storage;
using Microsoft.Extensions.DependencyInjection;
using EllieHub.Avalonia.DesignData.Common;
using EllieHub.Features.Common.ViewModels;
using EllieHub.DesignData.Common;
using EllieHub.ViewModels.Controls;
namespace EllieHub.Avalonia.DesignData.Controls;
namespace EllieHub.DesignData.Controls;
/// <summary>
/// Mock view-model for <see cref="UriInputBarViewModel"/>.

View file

@ -1,6 +1,6 @@
using EllieHub.Features.AppConfig.ViewModels;
using EllieHub.ViewModels.Windows;
namespace EllieHub.Avalonia.DesignData.Windows;
namespace EllieHub.DesignData.Windows;
/// <summary>
/// Mock view-model for <see cref="AboutMeViewModel"/>.

View file

@ -1,9 +1,9 @@
using Microsoft.Extensions.DependencyInjection;
using EllieHub.Avalonia.DesignData.Common;
using EllieHub.Features.AppWindow.ViewModels;
using EllieHub.Features.Home.ViewModels;
using EllieHub.DesignData.Common;
using EllieHub.ViewModels.Controls;
using EllieHub.ViewModels.Windows;
namespace EllieHub.Avalonia.DesignData.Windows;
namespace EllieHub.DesignData.Windows;
/// <summary>
/// Mock view-model for <see cref="AppViewModel"/>.

View file

@ -1,6 +1,6 @@
using EllieHub.Features.Home.ViewModels;
using EllieHub.ViewModels.Windows;
namespace EllieHub.Avalonia.DesignData.Windows;
namespace EllieHub.DesignData.Windows;
/// <summary>
/// Mock view-model for <see cref="UpdateViewModel"/>.

View file

@ -2,12 +2,13 @@
<PropertyGroup>
<!--Project Settings-->
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net8.0</TargetFramework>
<AnalysisLevel>latest</AnalysisLevel>
<LangVersion>latest</LangVersion>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<WarningsAsErrors>Nullable</WarningsAsErrors>
<TreatWarningsAsErrors>True</TreatWarningsAsErrors>
<EnforceCodeStyleInBuild>True</EnforceCodeStyleInBuild>
<GenerateDocumentationFile>True</GenerateDocumentationFile>
<BuiltInComInteropSupport>True</BuiltInComInteropSupport>
@ -15,12 +16,11 @@
<!--Publishing-->
<PublishSingleFile>true</PublishSingleFile>
<EnableCompressionInSingleFile>true</EnableCompressionInSingleFile>
<SelfContained>true</SelfContained>
<DebugType>embedded</DebugType>
<!--Version-->
<VersionPrefix>1.0.4.0</VersionPrefix>
<VersionPrefix>1.0.1.0</VersionPrefix>
<!--Avalonia Settings-->
<ApplicationManifest>app.manifest</ApplicationManifest>
@ -30,13 +30,6 @@
<UseAppHost>true</UseAppHost>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<TreatWarningsAsErrors>False</TreatWarningsAsErrors>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<TreatWarningsAsErrors>True</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
@ -46,23 +39,20 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.1.5" />
<PackageReference Include="Avalonia.Desktop" Version="11.1.5" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.1.5" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.1.5" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.1.5" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.1.5" />
<PackageReference Include="Toastie.DependencyInjection" Version="3.0.0" />
<PackageReference Include="Toastie.Events" Version="3.0.0" />
<PackageReference Include="MessageBox.Avalonia" Version="3.1.5.1" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="9.0.0" />
<PackageReference Include="Avalonia" Version="11.0.4" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.4" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.4" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.4" />
<PackageReference Include="Avalonia.Fonts.Inter" Version="11.0.4" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.4" />
<PackageReference Include="Toastie.Events" Version="2.1.0" />
<PackageReference Include="MessageBox.Avalonia" Version="3.1.4" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="7.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.10" />
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.0" />
<PackageReference Include="SingleFileExtractor.Core" Version="2.2.0" />
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.4" />
<PackageReference Include="Microsoft.Extensions.Http" Version="7.0.0" />
<PackageReference Include="SkiaImageView.Avalonia11" Version="1.5.0" />
<PackageReference Include="Toastie.Utilities" Version="3.0.0" />
</ItemGroup>
<ItemGroup>

View file

@ -1,15 +1,10 @@
using Avalonia.Controls;
using Microsoft.Extensions.DependencyInjection;
using EllieHub.Features.AppConfig.Models;
using EllieHub.Features.AppConfig.Services;
using EllieHub.Features.AppConfig.Services.Abstractions;
using EllieHub.Features.AppConfig.Services.Mocks;
using EllieHub.Features.AppWindow.Views.Windows;
using EllieHub.Features.BotConfig.Services;
using EllieHub.Features.BotConfig.Services.Abstractions;
using EllieHub.Features.BotConfig.Services.Mocks;
using EllieHub.Features.Home.Services;
using EllieHub.Features.Home.Services.Abstractions;
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;
@ -47,7 +42,7 @@ public static class IServiceCollectionExt
/// </summary>
/// <param name="serviceCollection">This service collection.</param>
/// <returns>This service collection with the services added.</returns>
public static IServiceCollection RegisterAppServices(this IServiceCollection serviceCollection)
public static IServiceCollection RegisterServices(this IServiceCollection serviceCollection)
{
// Design-time
if (Design.IsDesignMode)
@ -65,26 +60,15 @@ public static class IServiceCollectionExt
// Web requests
serviceCollection.AddHttpClient();
serviceCollection.AddHttpClient(AppConstants.NoRedirectClient) // Client that doesn't allow automatic redirections
.ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler() { AllowAutoRedirect = false });
serviceCollection.AddHttpClient(AppConstants.ToastielabClient)
.ConfigureHttpClient(x =>
{
x.DefaultRequestHeaders.Add("Accept", "application/vnd.toastielab+json");
x.DefaultRequestHeaders.Add("X-Toastielab-Api-Version", "2022-11-28");
#if DEBUG
x.DefaultRequestHeaders.UserAgent.TryParseAdd($"EllieHub v{AppStatics.AppVersion}-Debug");
#else
x.DefaultRequestHeaders.UserAgent.TryParseAdd($"EllieHub v{AppStatics.AppVersion}");
#endif
});
serviceCollection.AddHttpClient(AppConstants.NoRedirectClient) // Client that doesn't allow automatic reditections
.ConfigureHttpMessageHandlerBuilder(builder => builder.PrimaryHandler = new HttpClientHandler() { AllowAutoRedirect = false });
// App settings
serviceCollection.AddSingleton<IAppConfigManager, AppConfigManager>();
serviceCollection.AddSingleton<ReadOnlyAppSettings>();
serviceCollection.AddSingleton<ReadOnlyAppConfig>();
serviceCollection.AddSingleton(_ =>
(File.Exists(AppStatics.AppConfigUri))
? JsonSerializer.Deserialize<AppSettings>(File.ReadAllText(AppStatics.AppConfigUri)) ?? new()
? JsonSerializer.Deserialize<AppConfig>(File.ReadAllText(AppStatics.AppConfigUri)) ?? new()
: new()
);

View file

@ -0,0 +1,31 @@
using Microsoft.Extensions.DependencyInjection;
namespace EllieHub.Extensions;
/// <summary>
/// Defines extension methods for <see cref="IServiceProvider"/>.
/// </summary>
public static class IServiceProviderExt
{
/// <summary>
/// Gets service of type <typeparamref name="T"/> from the <see cref="IServiceProvider"/>.
/// </summary>
/// <typeparam name="T">The type of service object to get.</typeparam>
/// <param name="serviceProvider">The <see cref="IServiceProvider"/> to retrieve the service object from.</param>
/// <param name="arguments"></param>
/// <remarks>Do not use abstract types in the type argument!</remarks>
/// <returns>A service object of type <typeparamref name="T"/>.</returns>
/// <exception cref="ArgumentNullException">Occurs when <paramref name="serviceProvider"/> or <paramref name="arguments"/> are <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">Occurs when there is no concrete service of type <typeparamref name="T"/> or when the arguments are wrong.</exception>
public static T GetParameterizedService<T>(this IServiceProvider serviceProvider, params object[] arguments)
{
ArgumentNullException.ThrowIfNull(serviceProvider, nameof(serviceProvider));
ArgumentNullException.ThrowIfNull(arguments, nameof(arguments));
var result = ActivatorUtilities.CreateInstance<T>(serviceProvider, arguments);
return (result is null)
? throw new InvalidOperationException($"There is no service of type {nameof(T)} or the arguments were incorrect.")
: result;
}
}

View file

@ -83,8 +83,8 @@ public static class WindowExt
{
return (!activeView.TryFindResource(resourceName, theme, out var resource))
? throw new InvalidOperationException($"Resource '{resourceName}' was not found.")
: (resource is T castResource)
? castResource
: throw new InvalidCastException($"Could not convert resource of type '{resource?.GetType().FullName}' to '{nameof(T)}'.");
: (!Utilities.TryCastTo<T>(resource, out var result))
? throw new InvalidCastException($"Could not convert resource of type '{resource?.GetType()?.FullName}' to '{nameof(T)}'.")
: result;
}
}

View file

@ -1,11 +0,0 @@
namespace EllieHub.Features.AppWindow.Models;
/// <summary>
/// Represents a bot entry from EllieUpdater.
/// </summary>
/// <param name="Guid">The Id of the bot.</param>
/// <param name="Name">The name of the bot.</param>
/// <param name="IconUri">The path to the avatar image.</param>
/// <param name="Version">The version of the bot.</param>
/// <param name="PathUri">The path to the bot files.</param>
internal sealed record OldUpdaterBotEntry(Guid Guid, string Name, string? IconUri, string? Version, string? PathUri);

View file

@ -1,13 +0,0 @@
using System.Text.Json.Serialization;
namespace EllieHub.Features.BotConfig.Models.Api.Toastielab;
/// <summary>
/// The assets of a Toastielab release.
/// </summary>
/// <param name="Name">The name of the release file.</param>
/// <param name="Url">The url to the release file.</param>
public sealed record Assets(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("browser_download_url")] string Url
);

View file

@ -1,13 +0,0 @@
using System.Text.Json.Serialization;
namespace EllieHub.Features.BotConfig.Models.Api.Toastielab;
/// <summary>
/// Represents a release from the Toastielab API.
/// </summary>
/// <param name="Tag">The tag of the release.</param>
/// <param name="Assets">The assets of the release.</param>
public sealed record ToastielabRelease(
[property: JsonPropertyName("tag_name")] string Tag,
[property: JsonPropertyName("assets")] Assets[] Assets
);

View file

@ -1,13 +0,0 @@
using System.Text.Json.Serialization;
namespace EllieHub.Features.Home.Models.Api.Toastielab;
/// <summary>
/// The assets of a Toastielab release.
/// </summary>
/// <param name="Name">The name of the release file.</param>
/// <param name="Url">The url of the release file.</param>
public sealed record Assets(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("browser_download_url")] string Url
);

View file

@ -1,13 +0,0 @@
using System.Text.Json.Serialization;
namespace EllieHub.Features.Home.Models.Api.Toastielab;
/// <summary>
/// Represents a release from the Toastielab API.
/// </summary>
/// <param name="Tag">The tag of the release.</param>
/// <param name="Assets">The assets of the release.</param>
public sealed record ToastielabRelease(
[property: JsonPropertyName("tag_name")] string Tag,
[property: JsonPropertyName("assets")] Assets[] Assets
);

View file

@ -1,265 +0,0 @@
using Toastie.Utilities;
using Microsoft.Extensions.Caching.Memory;
using EllieHub.Features.Home.Models.Api.Toastielab;
using EllieHub.Features.Home.Services.Abstractions;
using System.IO.Compression;
using System.Runtime.InteropServices;
using System.Text.Json;
namespace EllieHub.Features.Home.Services;
/// <summary>
/// Defines a service that updates this application.
/// </summary>
public sealed class AppResolver : IAppResolver
{
private const string _cachedCurrentVersionKey = "currentVersion:EllieHub";
private const string _toastielabReleasesEndpointUrl = "https://toastielab.dev/api/v1/repos/EllieBotDevs/EllieHub/releases/latest";
private const string _toastielabReleasesRepoUrl = "https://toastielab.dev/EllieBotDevs/EllieHub/releases/latest";
private static readonly string _tempDirectory = Path.GetTempPath();
private static readonly string _downloadedFileName = GetDownloadFileName();
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _memoryCache;
/// <inheritdoc/>
public string DependencyName { get; } = "EllieHub";
/// <inheritdoc/>
public string OldFileSuffix { get; } = "_old";
/// <inheritdoc/>
public string FileName { get; }
/// <inheritdoc/>
public string BinaryUri { get; }
/// <summary>
/// Creates a service that updates this application.
/// </summary>
/// <param name="httpClientFactory">The Http client factory.</param>
/// <param name="memoryCache">The memory cache.</param>
public AppResolver(IHttpClientFactory httpClientFactory, IMemoryCache memoryCache)
{
_httpClientFactory = httpClientFactory;
_memoryCache = memoryCache;
FileName = OperatingSystem.IsWindows() ? "EllieHub.exe" : "EllieHub";
BinaryUri = Path.Join(AppContext.BaseDirectory, FileName);
}
/// <inheritdoc/>
public ValueTask<string?> GetCurrentVersionAsync(CancellationToken cToken = default)
=> ValueTask.FromResult<string?>(AppStatics.AppVersion);
/// <inheritdoc/>
public void LaunchNewVersion()
=> ToastieUtilities.StartProcess(BinaryUri);
/// <returns>
/// <see langword="true"/> if the updater can be updated,
/// <see langword="false"/> if the updater is up-to-date,
/// <see langword="null"/> if the updater cannot update itself.
/// </returns>
/// <inheritdoc/>
public async ValueTask<bool?> CanUpdateAsync(CancellationToken cToken = default)
{
if (!ToastieUtilities.HasWritePermissionAt(AppContext.BaseDirectory))
return null;
var currentVersion = await GetCurrentVersionAsync(cToken);
if (currentVersion is null)
return null;
var latestVersion = await GetLatestVersionAsync(cToken);
if (Version.Parse(latestVersion) <= Version.Parse(currentVersion))
return false;
var http = _httpClientFactory.CreateClient();
return await http.IsUrlValidAsync(
await GetDownloadUrlAsync(latestVersion, cToken),
cToken
);
}
/// <inheritdoc/>
public bool RemoveOldFiles()
{
var result = false;
foreach (var file in Directory.GetFiles(AppContext.BaseDirectory).Where(x => x.EndsWith(OldFileSuffix, StringComparison.Ordinal)))
result |= ToastieUtilities.TryDeleteFile(file);
return result;
}
/// <inheritdoc/>
public async ValueTask<string> GetLatestVersionAsync(CancellationToken cToken = default)
{
try
{
return (await GetLatestVersionFromApiAsync(cToken)).Tag;
}
catch (InvalidOperationException)
{
return await GetLatestVersionFromUrlAsync(cToken);
}
}
/// <inheritdoc/>
public async ValueTask<(string? OldVersion, string? NewVersion)> InstallOrUpdateAsync(string installationUri, CancellationToken cToken = default)
{
var currentVersion = await GetCurrentVersionAsync(cToken);
var latestVersion = await GetLatestVersionAsync(cToken);
if (currentVersion is not null && Version.Parse(latestVersion) <= Version.Parse(currentVersion))
return (currentVersion, null);
var http = _httpClientFactory.CreateClient(); // Do not initialize a ToastielabClient here, it returns 302 with no data
var appTempLocation = Path.Join(_tempDirectory, _downloadedFileName[.._downloadedFileName.LastIndexOf('.')]);
var zipTempLocation = Path.Join(_tempDirectory, _downloadedFileName);
try
{
await using var downloadStream = await http.GetStreamAsync(
await GetDownloadUrlAsync(latestVersion, cToken),
cToken
);
// Save the zip file
await 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.Join(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, true); // This executes fine
// Move the new file to the application's directory.
// ...
// This is a workaround for a really weird bug with Unix applications published as single-file.
// The moving operation works, but invoking any process from the shell results in:
// FileNotFoundException: Could not load file or assembly 'System.IO.Pipes, Version=9.0.0.0 [...]
if (Environment.OSVersion.Platform is not PlatformID.Unix)
File.Move(newFileUri, destinationUri, true);
else
{
using var moveProcess = ToastieUtilities.StartProcess("mv", [newFileUri, destinationUri]);
await moveProcess.WaitForExitAsync(cToken);
}
}
// Mark the new binary file as executable.c
if (Environment.OSVersion.Platform is PlatformID.Unix)
{
using var chmod = ToastieUtilities.StartProcess("chmod", ["+x", BinaryUri]);
await chmod.WaitForExitAsync(cToken);
}
return (currentVersion, latestVersion);
}
finally
{
// Cleanup
ToastieUtilities.TryDeleteFile(zipTempLocation);
ToastieUtilities.TryDeleteDirectory(appTempLocation);
}
}
/// <summary>
/// Gets the name of the file to be downloaded.
/// </summary>
/// <returns>The name of the file to be downloaded.</returns>
/// <exception cref="NotSupportedException">Occurs when this method is used in an unsupported system.</exception>
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.")
};
}
/// <summary>
/// Gets the download url to the latest bot release.
/// </summary>
/// <param name="latestVersion">The latest version of the bot.</param>
/// <param name="cToken">The cancellation token.</param>
/// <returns>The url to the latest bot release.</returns>
private async ValueTask<string> GetDownloadUrlAsync(string latestVersion, CancellationToken cToken = default)
{
try
{
// The first release is the most recent one.
return (await GetLatestVersionFromApiAsync(cToken)).Assets
.First(x => x.Name.Equals(_downloadedFileName, StringComparison.Ordinal))
.Url;
}
catch (InvalidOperationException)
{
return $"https://toastielab.dev/EllieBotDevs/EllieHub/releases/download/{latestVersion}/{_downloadedFileName}";
}
}
/// <summary>
/// Gets the latest bot version from the Toastielab latest release URL.
/// </summary>
/// <param name="cToken">The cancellation token.</param>
/// <returns>The latest version of the bot.</returns>
/// <exception cref="InvalidOperationException">Occurs when parsing of the response fails.</exception>
private async ValueTask<string> GetLatestVersionFromUrlAsync(CancellationToken cToken = default)
{
var http = _httpClientFactory.CreateClient(AppConstants.NoRedirectClient);
var response = await http.GetAsync(_toastielabReleasesRepoUrl, cToken);
var lastSlashIndex = response.Headers.Location?.OriginalString.LastIndexOf('/')
?? throw new InvalidOperationException("Failed to get the latest EllieBot version.");
return response.Headers.Location.OriginalString[(lastSlashIndex + 1)..];
}
/// <summary>
/// Gets the latest bot version from the Toastielab API.
/// </summary>
/// <param name="cToken">The cancellation token.</param>
/// <returns>The latest version of the bot.</returns>
/// <exception cref="InvalidOperationException">Occurs when the API call fails.</exception>
private async ValueTask<ToastielabRelease> GetLatestVersionFromApiAsync(CancellationToken cToken = default)
{
if (_memoryCache.TryGetValue(_cachedCurrentVersionKey, out var cachedObject) && cachedObject is ToastielabRelease cachedResponse)
return cachedResponse;
var http = _httpClientFactory.CreateClient(AppConstants.ToastielabClient);
var httpResponse = await http.GetAsync(_toastielabReleasesEndpointUrl, cToken);
if (!httpResponse.IsSuccessStatusCode)
throw new InvalidOperationException("The call to the Toastielab API failed.");
var response = JsonSerializer.Deserialize<ToastielabRelease>(await httpResponse.Content.ReadAsStringAsync(cToken))
?? throw new InvalidOperationException("Failed deserializing Toastielab's response.");
_memoryCache.Set(_cachedCurrentVersionKey, response, TimeSpan.FromMinutes(1));
return response;
}
}

View file

@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
namespace EllieHub.Features.AppConfig.Models.Api.Evermeet;
namespace EllieHub.Models.Api;
/// <summary>
/// Represents download information for a <see cref="EvermeetInfo"/> component.

View file

@ -1,6 +1,6 @@
using System.Text.Json.Serialization;
namespace EllieHub.Features.AppConfig.Models.Api.Evermeet;
namespace EllieHub.Models.Api;
/// <summary>
/// Represents a response from the "https://evermeet.cx/ffmpeg/info" endpoint.

View file

@ -1,6 +1,6 @@
using EllieHub.Features.AppWindow.ViewModels;
using EllieHub.ViewModels.Controls;
namespace EllieHub.Features.AppWindow.Models;
namespace EllieHub.Models;
/// <summary>
/// Represents a bot entry in the <see cref="LateralBarViewModel"/>.

View file

@ -1,4 +1,4 @@
namespace EllieHub.Features.AppWindow.Models;
namespace EllieHub.Models;
/// <summary>
/// Represents the information of a bot instance.

View file

@ -1,14 +1,13 @@
using EllieHub.Enums;
using EllieHub.Features.AppWindow.Models;
using System.Collections.Concurrent;
namespace EllieHub.Features.AppConfig.Models;
namespace EllieHub.Models.Config;
/// <summary>
/// Represents the settings of the application.
/// </summary>
/// <remarks>Prefer using <see cref="ReadOnlyAppSettings"/> in dependency injection, if possible.</remarks>
public sealed class AppSettings
/// <remarks>Prefer using <see cref="ReadOnlyAppConfig"/> in dependency injection, if possible.</remarks>
public sealed class AppConfig
{
/// <summary>
/// The absolute path to the directory where the bot instances are stored.

View file

@ -1,14 +1,13 @@
using EllieHub.Enums;
using EllieHub.Features.AppWindow.Models;
namespace EllieHub.Features.AppConfig.Models;
namespace EllieHub.Models.Config;
/// <summary>
/// Represents a read-only version of <see cref="AppSettings"/>.
/// Represents a read-only version of <see cref="AppConfig"/>.
/// </summary>
public sealed class ReadOnlyAppSettings
public sealed class ReadOnlyAppConfig
{
private readonly AppSettings _appConfig;
private readonly AppConfig _appConfig;
/// <summary>
/// The absolute path to the directory where the bot instances are stored.
@ -65,9 +64,9 @@ public sealed class ReadOnlyAppSettings
=> _appConfig.BotEntries;
/// <summary>
/// Initializes a read-only version of <see cref="AppSettings"/>.
/// Initializes a read-only version of <see cref="AppConfig"/>.
/// </summary>
/// <param name="appConfig">The application settings to read from.</param>
public ReadOnlyAppSettings(AppSettings appConfig)
public ReadOnlyAppConfig(AppConfig appConfig)
=> _appConfig = appConfig;
}

View file

@ -1,6 +1,6 @@
using SkiaSharp;
namespace EllieHub.Features.BotConfig.Models;
namespace EllieHub.Models.EventArguments;
/// <summary>
/// Defines the event arguments for when the user sets a new avatar for a bot instance.

View file

@ -1,4 +1,4 @@
namespace EllieHub.Features.BotConfig.Models;
namespace EllieHub.Models.EventArguments;
/// <summary>
/// Defines the event arguments when a bot process exits.
@ -15,21 +15,14 @@ public sealed class BotExitEventArgs : EventArgs
/// </summary>
public int ExitCode { get; }
/// <summary>
/// The exit message.
/// </summary>
public string Message { get; }
/// <summary>
/// Creates the event arguments when a bot process exits.
/// </summary>
/// <param name="botId">The bot's Id.</param>
/// <param name="exitCode">The exit code.</param>
/// <param name="message">The message for the bot process that just exited.</param>
public BotExitEventArgs(Guid botId, int exitCode, string message)
public BotExitEventArgs(Guid botId, int exitCode)
{
Id = botId;
ExitCode = exitCode;
Message = message;
}
}

View file

@ -1,4 +1,4 @@
namespace EllieHub.Features.BotConfig.Models;
namespace EllieHub.Models.EventArguments;
/// <summary>
/// Defines the event arguments when a log is written to disk.

View file

@ -1,4 +1,4 @@
namespace EllieHub.Features.BotConfig.Models;
namespace EllieHub.Models.EventArguments;
/// <summary>
/// Defines the event arguments when a bot process writes to stdout or stderr.

View file

@ -1,6 +1,6 @@
using EllieHub.Features.Common.ViewModels;
using EllieHub.ViewModels.Controls;
namespace EllieHub.Features.Common.Models;
namespace EllieHub.Models.EventArguments;
/// <summary>
/// Defines the event arguments for when a valid uri is set to a <see cref="UriInputBarViewModel"/>.

View file

@ -1,4 +1,4 @@
namespace EllieHub.Features.AppConfig.Models;
namespace EllieHub.Models;
/// <summary>
/// Represents the dimensions of the application's window.

View file

@ -21,14 +21,14 @@
<SolidColorBrush x:Key='LightBackground'>#FAF5F8</SolidColorBrush>
<SolidColorBrush x:Key='MediumBackground'>#FCFAFC</SolidColorBrush>
<SolidColorBrush x:Key='HeavyBackground'>#FFFAFD</SolidColorBrush>
<SolidColorBrush x:Key='BotSelectionColor'>#3498DB</SolidColorBrush>
<SolidColorBrush x:Key='BotSelectionColor'>#FF0067</SolidColorBrush>
<SolidColorBrush x:Key='HyperlinkColor'>Blue</SolidColorBrush>
</ResourceDictionary>
<ResourceDictionary x:Key='Dark'>
<SolidColorBrush x:Key='LightBackground'>#252525</SolidColorBrush>
<SolidColorBrush x:Key='MediumBackground'>#202020</SolidColorBrush>
<SolidColorBrush x:Key='HeavyBackground'>#181818</SolidColorBrush>
<SolidColorBrush x:Key='BotSelectionColor'>#206694</SolidColorBrush>
<SolidColorBrush x:Key='BotSelectionColor'>#D90058</SolidColorBrush>
<SolidColorBrush x:Key='HyperlinkColor'>#5090BB</SolidColorBrush>
</ResourceDictionary>
</ResourceDictionary.ThemeDictionaries>

View file

@ -1,9 +1,5 @@
<ResourceDictionary xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<FontFamily x:Key="NotoSansFont">avares://EllieHub/Assets/Fonts/NotoSans-Regular.ttf</FontFamily>
<FontFamily x:Key="NotoSansBoldFont">avares://EllieHub/Assets/Fonts/NotoSans-Bold.ttf</FontFamily>
<!--Preview-->
<Design.PreviewWith>
<Border Padding="20">
@ -17,4 +13,7 @@
</StackPanel>
</Border>
</Design.PreviewWith>
<FontFamily x:Key="NotoSansFont">avares://EllieHub/Assets/Fonts/NotoSans-Regular.ttf</FontFamily>
<FontFamily x:Key="NotoSansBoldFont">avares://EllieHub/Assets/Fonts/NotoSans-Bold.ttf</FontFamily>
</ResourceDictionary>

View file

@ -50,7 +50,7 @@
<Bitmap x:Key="UrlIcon">avares://EllieHub/Assets/Light/icon-link.png</Bitmap>
<Bitmap x:Key="SuggestionIcon">avares://EllieHub/Assets/Light/icon-suggest.png</Bitmap>
<Bitmap x:Key="DiscordIcon">avares://EllieHub/Assets/Light/icon-support.png</Bitmap>
<WindowIcon x:Key="EllieHubIcon"><![CDATA[avares://EllieHub/Assets/Light/ellieupdatericon.ico]]></WindowIcon>
<WindowIcon x:Key="EllieHubIcon">avares://EllieHub/Assets/Light/ellieupdatericon.ico</WindowIcon>
<Bitmap x:Key="EllieHubImage">avares://EllieHub/Assets/Light/ellieupdatericon.ico</Bitmap>
<Bitmap x:Key="TerminalIcon">avares://EllieHub/Assets/Light/terminal.png</Bitmap>
</ResourceDictionary>
@ -65,7 +65,7 @@
<Bitmap x:Key="UrlIcon">avares://EllieHub/Assets/Dark/icon-link.png</Bitmap>
<Bitmap x:Key="SuggestionIcon">avares://EllieHub/Assets/Dark/icon-suggest.png</Bitmap>
<Bitmap x:Key="DiscordIcon">avares://EllieHub/Assets/Dark/icon-support.png</Bitmap>
<WindowIcon x:Key="EllieHubIcon"><![CDATA[avares://EllieHub/Assets/Dark/ellieupdatericon.ico]]></WindowIcon>
<WindowIcon x:Key="EllieHubIcon">avares://EllieHub/Assets/Dark/ellieupdatericon.ico</WindowIcon>
<Bitmap x:Key="EllieHubImage">avares://EllieHub/Assets/Dark/ellieupdatericon.ico</Bitmap>
<Bitmap x:Key="TerminalIcon">avares://EllieHub/Assets/Dark/terminal.png</Bitmap>
</ResourceDictionary>

View file

@ -1,13 +1,11 @@
using Toastie.Utilities;
namespace EllieHub.Features.AppConfig.Services.Abstractions;
namespace EllieHub.Services.Abstractions;
/// <summary>
/// Base class for a service that checks, downloads, installs, and updates ffmpeg.
/// </summary>
public abstract class FfmpegResolver : IFfmpegResolver
{
private readonly string _programVerifier = Environment.OSVersion.Platform is PlatformID.Win32NT ? "where" : "which";
private readonly string _programVerifier = (Environment.OSVersion.Platform is PlatformID.Win32NT) ? "where" : "which";
/// <summary>
/// The name of the Ffmpeg process.
@ -15,7 +13,7 @@ public abstract class FfmpegResolver : IFfmpegResolver
protected const string FfmpegProcessName = "ffmpeg";
/// <inheritdoc/>
public string DependencyName { get; } = "Ffmpeg";
public string DependencyName { get; } = "FFMPEG";
/// <inheritdoc/>
public abstract string FileName { get; }
@ -24,7 +22,7 @@ public abstract class FfmpegResolver : IFfmpegResolver
public virtual async ValueTask<bool?> CanUpdateAsync(CancellationToken cToken = default)
{
// Check where ffmpeg is referenced.
using var whereProcess = ToastieUtilities.StartProcess(_programVerifier, FfmpegProcessName, true);
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.
@ -34,7 +32,7 @@ public abstract class FfmpegResolver : IFfmpegResolver
var currentVer = await GetCurrentVersionAsync(cToken);
// If ffmpeg or ffprobe are absent, a reinstall needs to be performed.
if (currentVer is null || !ToastieUtilities.ProgramExists("ffprobe"))
if (currentVer is null || !await Utilities.ProgramExistsAsync("ffprobe", cToken))
return null;
var latestVer = await GetLatestVersionAsync(cToken);
@ -46,20 +44,20 @@ public abstract class FfmpegResolver : IFfmpegResolver
public virtual async ValueTask<string?> GetCurrentVersionAsync(CancellationToken cToken = default)
{
// If ffmpeg is not accessible from the shell...
if (!ToastieUtilities.ProgramExists(FfmpegProcessName))
if (!await Utilities.ProgramExistsAsync(FfmpegProcessName, cToken))
{
// And doesn't exist in the dependencies folder,
// report that ffmpeg is not installed.
if (!File.Exists(Path.Join(AppStatics.AppDepsUri, FileName)))
if (!File.Exists(Path.Combine(AppStatics.AppDepsUri, FileName)))
return null;
// Else, add the dependencies directory to the PATH envar,
// then try again.
ToastieUtilities.AddPathToPATHEnvar(AppStatics.AppDepsUri);
Utilities.AddPathToPATHEnvar(AppStatics.AppDepsUri);
return await GetCurrentVersionAsync(cToken);
}
using var ffmpeg = ToastieUtilities.StartProcess(FfmpegProcessName, "-version", true);
using var ffmpeg = Utilities.StartProcess(FfmpegProcessName, "-version");
var match = AppStatics.FfmpegVersionRegex.Match(await ffmpeg.StandardOutput.ReadLineAsync(cToken) ?? string.Empty);
return match.Groups[1].Value;

View file

@ -1,7 +1,7 @@
using EllieHub.Features.AppConfig.Models;
using EllieHub.Features.AppWindow.Models;
using EllieHub.Models;
using EllieHub.Models.Config;
namespace EllieHub.Features.AppConfig.Services.Abstractions;
namespace EllieHub.Services.Abstractions;
/// <summary>
/// Represents a service that manages the application's settings.
@ -11,7 +11,7 @@ public interface IAppConfigManager
/// <summary>
/// The application settings.
/// </summary>
ReadOnlyAppSettings AppConfig { get; }
ReadOnlyAppConfig AppConfig { get; }
/// <summary>
/// Creates a bot entry.
@ -52,5 +52,5 @@ public interface IAppConfigManager
/// </summary>
/// <param name="action">The action to be performed on the configuration file.</param>
/// <param name="cToken">The cancellation token.</param>
ValueTask UpdateConfigAsync(Action<AppSettings> action, CancellationToken cToken = default);
ValueTask UpdateConfigAsync(Action<AppConfig> action, CancellationToken cToken = default);
}

View file

@ -1,6 +1,4 @@
using EllieHub.Features.Common.Services.Abstractions;
namespace EllieHub.Features.Home.Services.Abstractions;
namespace EllieHub.Services.Abstractions;
/// <summary>
/// Represents a service that updates this application.

View file

@ -1,9 +1,9 @@
using EllieHub.Features.BotConfig.Models;
using EllieHub.Models.EventArguments;
namespace EllieHub.Features.BotConfig.Services.Abstractions;
namespace EllieHub.Services.Abstractions;
/// <summary>
/// Represents an object that coordinates multiple running processes of EllieBot.
/// Represents an object that coordinates multiple running processes of Ellie.
/// </summary>
public interface IBotOrchestrator
{
@ -34,18 +34,18 @@ public interface IBotOrchestrator
/// </summary>
/// <param name="botId">The bot's Id.</param>
/// <returns><see langword="true"/> if the bot successfully started, <see langword="false"/> otherwise.</returns>
bool StartBot(Guid botId);
bool Start(Guid botId);
/// <summary>
/// Stops the bot with the specified <paramref name="botId"/>.
/// </summary>
/// <param name="botId">The bot's Id.</param>
/// <returns><see langword="true"/> if the bot successfully stopped, <see langword="false"/> otherwise.</returns>
bool StopBot(Guid botId);
bool Stop(Guid botId);
/// <summary>
/// Stops all bot instances.
/// </summary>
/// <returns><see langword="true"/> if at least one bot instance was stopped, <see langword="false"/> otherwise.</returns>
bool StopAllBots();
bool StopAll();
}

View file

@ -1,6 +1,4 @@
using EllieHub.Features.Common.Services.Abstractions;
namespace EllieHub.Features.BotConfig.Services.Abstractions;
namespace EllieHub.Services.Abstractions;
/// <summary>
/// Represents a service that checks, downloads, installs, and updates a bot instance.
@ -12,11 +10,6 @@ public interface IBotResolver : IDependencyResolver
/// </summary>
string BotName { get; }
/// <summary>
/// Defines whether there is an ongoing update.
/// </summary>
bool IsUpdateInProgress { get; }
/// <summary>
/// The Id of the bot.
/// </summary>

View file

@ -1,4 +1,4 @@
namespace EllieHub.Features.Common.Services.Abstractions;
namespace EllieHub.Services.Abstractions;
/// <summary>
/// Represents a service that checks, downloads, installs, and updates a dependency.

View file

@ -1,6 +1,4 @@
using EllieHub.Features.Common.Services.Abstractions;
namespace EllieHub.Features.AppConfig.Services.Abstractions;
namespace EllieHub.Services.Abstractions;
/// <summary>
/// Represents a service that checks, downloads, installs, and updates ffmpeg.

View file

@ -1,7 +1,7 @@
using EllieHub.Features.BotConfig.Models;
using EllieHub.Models.EventArguments;
using System.Diagnostics.CodeAnalysis;
namespace EllieHub.Features.BotConfig.Services.Abstractions;
namespace EllieHub.Services.Abstractions;
/// <summary>
/// Represents a service that writes logs of bot instances to the disk.

View file

@ -1,6 +1,4 @@
using EllieHub.Features.Common.Services.Abstractions;
namespace EllieHub.Features.AppConfig.Services.Abstractions;
namespace EllieHub.Services.Abstractions;
/// <summary>
/// Represents a service that checks, downloads, installs, and updates yt-dlp.

View file

@ -1,10 +1,9 @@
using Toastie.Utilities;
using EllieHub.Features.AppConfig.Models;
using EllieHub.Features.AppConfig.Services.Abstractions;
using EllieHub.Features.AppWindow.Models;
using EllieHub.Models;
using EllieHub.Models.Config;
using EllieHub.Services.Abstractions;
using System.Text.Json;
namespace EllieHub.Features.AppConfig.Services;
namespace EllieHub.Services;
/// <summary>
/// Defines a service that manages the application's settings.
@ -12,15 +11,15 @@ namespace EllieHub.Features.AppConfig.Services;
public sealed class AppConfigManager : IAppConfigManager
{
private readonly JsonSerializerOptions _jsonSerializerOptions = new() { WriteIndented = true };
private readonly AppSettings _appConfig;
private readonly AppConfig _appConfig;
/// <inheritdoc/>
public ReadOnlyAppSettings AppConfig { get; }
public ReadOnlyAppConfig AppConfig { get; }
/// <summary>
/// Creates a service that manages the application's settings.
/// </summary>
public AppConfigManager(AppSettings appConfig, ReadOnlyAppSettings readOnlyAppConfig)
public AppConfigManager(AppConfig appConfig, ReadOnlyAppConfig readOnlyAppConfig)
{
_appConfig = appConfig;
AppConfig = readOnlyAppConfig;
@ -32,9 +31,9 @@ public sealed class AppConfigManager : IAppConfigManager
public async ValueTask<BotEntry> CreateBotEntryAsync(CancellationToken cToken = default)
{
var newId = CreateNewId();
var newPosition = _appConfig.BotEntries.Count is 0 ? 0 : _appConfig.BotEntries.Values.Max(x => x.Position) + 1;
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.Join(_appConfig.BotsDirectoryUri, newBotName), 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}.");
@ -50,7 +49,7 @@ public sealed class AppConfigManager : IAppConfigManager
if (!_appConfig.BotEntries.TryRemove(id, out var removedEntry))
return null;
ToastieUtilities.TryDeleteDirectory(removedEntry.InstanceDirectoryUri);
Utilities.TryDeleteDirectory(removedEntry.InstanceDirectoryUri);
await SaveAsync(cToken);
@ -91,7 +90,7 @@ public sealed class AppConfigManager : IAppConfigManager
}
/// <inheritdoc/>
public ValueTask UpdateConfigAsync(Action<AppSettings> action, CancellationToken cToken = default)
public ValueTask UpdateConfigAsync(Action<AppConfig> action, CancellationToken cToken = default)
{
action(_appConfig);
return SaveAsync(cToken);

View file

@ -0,0 +1,185 @@
using EllieHub.Services.Abstractions;
using System.IO.Compression;
using System.Reflection;
using System.Runtime.InteropServices;
namespace EllieHub.Services;
/// <summary>
/// Defines a service that updates this application.
/// </summary>
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;
/// <inheritdoc/>
public string DependencyName { get; } = "EllieHub";
/// <inheritdoc/>
public string OldFileSuffix { get; } = "_old";
/// <inheritdoc/>
public string FileName { get; }
/// <inheritdoc/>
public string BinaryUri { get; }
/// <summary>
/// Creates a service that updates this application.
/// </summary>
/// <param name="httpClientFactory">The Http client factory.</param>
public AppResolver(IHttpClientFactory httpClientFactory)
{
_httpClientFactory = httpClientFactory;
FileName = (OperatingSystem.IsWindows()) ? "EllieHub.exe" : "EllieHub";
BinaryUri = Path.Combine(AppContext.BaseDirectory, FileName);
}
/// <inheritdoc/>
public ValueTask<string?> GetCurrentVersionAsync(CancellationToken cToken = default)
=> ValueTask.FromResult(_currentUpdaterVersion);
/// <inheritdoc/>
public void LaunchNewVersion()
=> Utilities.StartProcess(BinaryUri);
/// <returns>
/// <see langword="true"/> if the updater can be updated,
/// <see langword="false"/> if the updater is up-to-date,
/// <see langword="null"/> if the updater cannot update itself.
/// </returns>
/// <inheritdoc/>
public async ValueTask<bool?> 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);
}
/// <inheritdoc/>
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;
}
/// <inheritdoc/>
public async ValueTask<string> 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)..];
}
/// <inheritdoc/>
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);
}
}
/// <summary>
/// Gets the name of the file to be downloaded.
/// </summary>
/// <returns>The name of the file to be downloaded.</returns>
/// <exception cref="NotSupportedException">Occurs when this method is used in an unsupported system.</exception>
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.")
};
}
}

View file

@ -1,19 +1,18 @@
using EllieHub.Features.AppConfig.Models;
using EllieHub.Features.BotConfig.Models;
using EllieHub.Features.BotConfig.Services.Abstractions;
using EllieHub.Models.Config;
using EllieHub.Models.EventArguments;
using EllieHub.Services.Abstractions;
using System.Diagnostics;
namespace EllieHub.Features.BotConfig.Services;
namespace EllieHub.Services;
/// <summary>
/// Defines an object that coordinates multiple running processes of EllieBot.
/// </summary>
public sealed class EllieOrchestrator : IBotOrchestrator
{
private readonly ReadOnlyAppSettings _appConfig;
private readonly ILogWriter _logWriter;
private readonly Dictionary<Guid, Process> _runningBots = new();
private readonly string _fileName = OperatingSystem.IsWindows() ? "EllieBot.exe" : "EllieBot";
private readonly ReadOnlyAppConfig _appConfig;
private readonly string _fileName = (OperatingSystem.IsWindows()) ? "NadekoBot.exe" : "NadekoBot";
/// <inheritdoc/>
public event EventHandler<IBotOrchestrator, BotExitEventArgs>? OnBotExit;
@ -28,28 +27,24 @@ public sealed class EllieOrchestrator : IBotOrchestrator
/// Creates an object that coordinates multiple running processes of EllieBot.
/// </summary>
/// <param name="appConfig">The application settings.</param>
/// <param name="logWriter">The service that writes bot logs to disk.</param>
public EllieOrchestrator(ReadOnlyAppSettings appConfig, ILogWriter logWriter)
{
_appConfig = appConfig;
_logWriter = logWriter;
}
public EllieOrchestrator(ReadOnlyAppConfig appConfig)
=> _appConfig = appConfig;
/// <inheritdoc/>
public bool IsBotRunning(Guid botId)
=> _runningBots.ContainsKey(botId);
/// <inheritdoc/>
public bool StartBot(Guid botId)
public bool Start(Guid botId)
{
if (_runningBots.ContainsKey(botId)
|| !_appConfig.BotEntries.TryGetValue(botId, out var botEntry)
|| !File.Exists(Path.Join(botEntry.InstanceDirectoryUri, _fileName)))
|| !File.Exists(Path.Combine(botEntry.InstanceDirectoryUri, _fileName)))
return false;
var botProcess = Process.Start(new ProcessStartInfo()
{
FileName = Path.Join(botEntry.InstanceDirectoryUri, _fileName),
FileName = Path.Combine(botEntry.InstanceDirectoryUri, _fileName),
WorkingDirectory = botEntry.InstanceDirectoryUri,
UseShellExecute = false,
CreateNoWindow = true,
@ -71,7 +66,7 @@ public sealed class EllieOrchestrator : IBotOrchestrator
}
/// <inheritdoc/>
public bool StopBot(Guid botId)
public bool Stop(Guid botId)
{
if (!_runningBots.TryGetValue(botId, out var botProcess))
return false;
@ -81,11 +76,10 @@ public sealed class EllieOrchestrator : IBotOrchestrator
}
/// <inheritdoc/>
public bool StopAllBots()
public bool StopAll()
{
var amount = _runningBots.Count;
// ReSharper disable once EmptyGeneralCatchClause
foreach (var process in _runningBots.Values)
try { process.Kill(true); } catch { }
@ -101,12 +95,7 @@ public sealed class EllieOrchestrator : IBotOrchestrator
private void OnExit(object? sender, EventArgs eventArgs)
{
var (id, process) = _runningBots.First(x => x.Value.Equals(sender));
var message = Environment.NewLine
+ $"{_appConfig.BotEntries[id].Name} stopped. Status code: {process.ExitCode}"
+ Environment.NewLine;
_logWriter.TryAdd(id, message);
OnBotExit?.Invoke(this, new(id, process.ExitCode, message));
OnBotExit?.Invoke(this, new(id, process.ExitCode));
_runningBots.Remove(id);
process.CancelOutputRead();
@ -127,7 +116,6 @@ public sealed class EllieOrchestrator : IBotOrchestrator
var (id, _) = _runningBots.First(x => x.Value.Equals(sender));
var newEventArgs = new ProcessStdWriteEventArgs(id, eventArgs.Data);
_logWriter.TryAdd(id, eventArgs.Data);
OnStdout?.Invoke(this, newEventArgs);
}
@ -144,7 +132,6 @@ public sealed class EllieOrchestrator : IBotOrchestrator
var (id, _) = _runningBots.First(x => x.Value.Equals(sender));
var newEventArgs = new ProcessStdWriteEventArgs(id, eventArgs.Data);
_logWriter.TryAdd(id, eventArgs.Data);
OnStderr?.Invoke(this, newEventArgs);
}
}

View file

@ -1,43 +1,29 @@
using Toastie.Utilities;
using Microsoft.Extensions.Caching.Memory;
using EllieHub.Features.AppConfig.Services.Abstractions;
using EllieHub.Features.BotConfig.Models.Api.Toastielab;
using EllieHub.Features.BotConfig.Services.Abstractions;
using SingleFileExtractor.Core;
using EllieHub.Services.Abstractions;
using System.Formats.Tar;
using System.IO.Compression;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.RegularExpressions;
namespace EllieHub.Features.BotConfig.Services;
namespace EllieHub.Services;
/// <summary>
/// Service that checks, downloads, installs, and updates a EllieBot instance.
/// Service that checks, downloads, installs, and updates a NadekoBot instance.
/// </summary>
/// <remarks>Source: https://toastielab.dev/EllieBotDevs/elliebot/releases/latest</remarks>
/// <remarks>Source: https://gitlab.com/Kwoth/nadekobot/-/releases/permalink/latest</remarks>
public sealed partial class EllieResolver : IBotResolver
{
private const string _cachedCurrentVersionKey = "currentVersion:EllieBot";
private const string _toastielabReleasesEndpointUrl = "https://toastielab.dev/api/v1/repos/EllieBotDevs/elliebot/releases/latest";
private const string _toastielabReleasesRepoUrl = "https://toastielab.dev/EllieBotDevs/elliebot/releases/latest";
private static readonly HashSet<Guid> _updateIdOngoing = [];
private static readonly HashSet<Guid> _updateIdOngoing = new();
private static readonly string _tempDirectory = Path.GetTempPath();
private static readonly Regex _unzippedDirRegex = GenerateUnzipedDirRegex();
private static readonly Regex _unzipedDirRegex = GenerateUnzipedDirRegex();
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _memoryCache;
private readonly IAppConfigManager _appConfigManager;
/// <inheritdoc/>
public string DependencyName { get; } = "EllieBot";
public string DependencyName { get; } = "NadekoBot";
/// <inheritdoc/>
public string FileName { get; } = (OperatingSystem.IsWindows()) ? "EllieBot.exe" : "EllieBot";
/// <inheritdoc/>
public bool IsUpdateInProgress
=> _updateIdOngoing.Contains(Id);
public string FileName { get; } = (OperatingSystem.IsWindows()) ? "NadekoBot.exe" : "NadekoBot";
/// <inheritdoc/>
public Guid Id { get; }
@ -46,16 +32,14 @@ public sealed partial class EllieResolver : IBotResolver
public string BotName { get; }
/// <summary>
/// Creates a service that checks, downloads, installs, and updates a EllieBot instance.
/// Creates a service that checks, downloads, installs, and updates a NadekoBot instance.
/// </summary>
/// <param name="httpClientFactory">The HTTP client factory.</param>
/// <param name="memoryCache">The memory cache.</param>
/// <param name="appConfigManager">The application's settings.</param>
/// <param name="botId">The Id of the bot.</param>
public EllieResolver(IHttpClientFactory httpClientFactory, IMemoryCache memoryCache, IAppConfigManager appConfigManager, Guid botId)
public EllieResolver(IHttpClientFactory httpClientFactory, IAppConfigManager appConfigManager, Guid botId)
{
_httpClientFactory = httpClientFactory;
_memoryCache = memoryCache;
_appConfigManager = appConfigManager;
Id = botId;
BotName = _appConfigManager.AppConfig.BotEntries[Id].Name;
@ -71,13 +55,13 @@ public sealed partial class EllieResolver : IBotResolver
var latestVersion = await GetLatestVersionAsync(cToken);
if (Version.Parse(latestVersion) <= Version.Parse(currentVersion))
if (latestVersion.Equals(currentVersion, StringComparison.Ordinal))
return false;
var http = _httpClientFactory.CreateClient();
return await http.IsUrlValidAsync(
await GetDownloadUrlAsync(latestVersion, cToken),
$"https://gitlab.com/api/v4/projects/9321079/packages/generic/NadekoBot-build/{latestVersion}/{GetDownloadFileName(latestVersion)}",
cToken
);
}
@ -94,8 +78,8 @@ public sealed partial class EllieResolver : IBotResolver
var now = DateTimeOffset.Now;
var date = new DateOnly(now.Year, now.Month, now.Day).ToShortDateString().Replace('/', '-');
var backupZipName = $"{botInstance.Name}_v{botInstance.Version}_{date}-{now.ToUnixTimeMilliseconds()}.zip";
var destinationUri = Path.Join(_appConfigManager.AppConfig.BotsBackupDirectoryUri, backupZipName);
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.
@ -108,36 +92,42 @@ public sealed partial class EllieResolver : IBotResolver
public async ValueTask<string?> GetCurrentVersionAsync(CancellationToken cToken = default)
{
var botEntry = _appConfigManager.AppConfig.BotEntries[Id];
var executableUri = Path.Join(botEntry.InstanceDirectoryUri, FileName);
if (!File.Exists(executableUri))
{
await _appConfigManager.UpdateBotEntryAsync(Id, x => x with { Version = null }, cToken);
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}.");
return (string.IsNullOrWhiteSpace(botEntry.Version))
? await GetBotVersionFromAssemblyAsync(executableUri, cToken)
: botEntry.Version;
var currentVersion = $"{version.Major}.{version.Minor}.{version.Build}";
await _appConfigManager.UpdateBotEntryAsync(Id, x => x with { Version = currentVersion }, cToken);
return currentVersion;
}
/// <inheritdoc/>
public async ValueTask<string> GetLatestVersionAsync(CancellationToken cToken = default)
{
try
{
return (await GetLatestVersionFromApiAsync(cToken)).Tag;
}
catch (InvalidOperationException)
{
return await GetLatestVersionFromUrlAsync(cToken);
}
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)..];
}
/// <inheritdoc/>
public async ValueTask<(string? OldVersion, string? NewVersion)> InstallOrUpdateAsync(string installationUri, CancellationToken cToken = default)
{
if (IsUpdateInProgress)
if (_updateIdOngoing.Contains(Id))
return (null, null);
_updateIdOngoing.Add(Id);
@ -145,8 +135,8 @@ public sealed partial class EllieResolver : IBotResolver
var currentVersion = await GetCurrentVersionAsync(cToken);
var latestVersion = await GetLatestVersionAsync(cToken);
// Already up-to-date, quit
if (currentVersion is not null && Version.Parse(latestVersion) <= Version.Parse(currentVersion))
// Update
if (latestVersion == currentVersion)
{
_updateIdOngoing.Remove(Id);
return (currentVersion, null);
@ -162,13 +152,13 @@ public sealed partial class EllieResolver : IBotResolver
var http = _httpClientFactory.CreateClient();
var downloadFileName = GetDownloadFileName(latestVersion);
var botTempLocation = Path.Join(_tempDirectory, "elliebot-" + _unzippedDirRegex.Match(downloadFileName).Groups[1].Value);
var zipTempLocation = Path.Join(_tempDirectory, downloadFileName);
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(
await GetDownloadUrlAsync(latestVersion, cToken),
$"https://gitlab.com/api/v4/projects/9321079/packages/generic/NadekoBot-build/{latestVersion}/{downloadFileName}",
cToken
);
@ -186,10 +176,10 @@ public sealed partial class EllieResolver : IBotResolver
await _appConfigManager.UpdateBotEntryAsync(Id, x => x with { Version = latestVersion }, cToken);
// Create creds.yml
var credsUri = Path.Join(installationUri, "creds.yml");
var credsUri = Path.Combine(installationUri, "creds.yml");
if (!File.Exists(credsUri))
File.Copy(Path.Join(installationUri, "creds_example.yml"), credsUri);
File.Copy(Path.Combine(installationUri, "creds_example.yml"), credsUri);
return (currentVersion, latestVersion);
}
@ -198,13 +188,13 @@ public sealed partial class EllieResolver : IBotResolver
_updateIdOngoing.Remove(Id);
// Cleanup
ToastieUtilities.TryDeleteFile(zipTempLocation);
ToastieUtilities.TryDeleteDirectory(botTempLocation);
Utilities.TryDeleteFile(zipTempLocation);
Utilities.TryDeleteDirectory(botTempLocation);
}
}
/// <summary>
/// Installs the Ellie instance on a Unix system.
/// Installs the Nadeko instance on a Unix system.
/// </summary>
/// <param name="downloadStream">The stream of data downloaded from the source.</param>
/// <param name="installationUri">The absolute path to the directory the bot got installed to.</param>
@ -215,25 +205,27 @@ public sealed partial class EllieResolver : IBotResolver
// Extract the tar ball
await TarFile.ExtractToDirectoryAsync(downloadStream, _tempDirectory, true, cToken);
ToastieUtilities.TryMoveDirectory(botTempLocation, installationUri);
// 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 = ToastieUtilities.StartProcess("chmod", ["+x", Path.Join(installationUri, FileName)]);
using var chmod = Utilities.StartProcess("chmod", $"+x \"{Path.Combine(installationUri, FileName)}\"");
await chmod.WaitForExitAsync(cToken);
}
/// <summary>
/// Installs the Ellie instance on a non-Unix system.
/// Installs the Nadeko instance on a non-Unix system.
/// </summary>
/// <param name="downloadStream">The stream of data downloaded from the source.</param>
/// <param name="installationUri">The absolute path to the directory the bot got installed to.</param>
/// <param name="zipTempLocation">The absolute path to the zip file the bot is initially on.</param>
/// <param name="botTempLocation">The absolute path to the temporary directory the bot is extracted to.</param>
/// <param name="cToken">The cancellation token.</param>
private static async ValueTask InstallToWindowsAsync(Stream downloadStream, string installationUri, string zipTempLocation, string botTempLocation, CancellationToken cToken = default)
private async static ValueTask InstallToWindowsAsync(Stream downloadStream, string installationUri, string zipTempLocation, string botTempLocation, CancellationToken cToken = default)
{
// Save the zip file
await using (var fileStream = new FileStream(zipTempLocation, FileMode.Create))
using (var fileStream = new FileStream(zipTempLocation, FileMode.Create))
await downloadStream.CopyToAsync(fileStream, cToken);
// Extract the zip file
@ -249,13 +241,13 @@ public sealed partial class EllieResolver : IBotResolver
/// <param name="installationUri">The absolute path to the directory the bot got installed to.</param>
/// <param name="backupFileUri">The absolute path to the backup zip file.</param>
/// <param name="cToken">The cancellation token.</param>
private static async ValueTask ReaplyBotSettingsAsync(string installationUri, string backupFileUri, CancellationToken cToken = default)
private async static ValueTask ReaplyBotSettingsAsync(string installationUri, string backupFileUri, CancellationToken cToken = default)
{
using var zipFile = ZipFile.OpenRead(backupFileUri);
var zippedFiles = zipFile.Entries
.Where(x =>
x.Name is "creds.yml" // Restore creds.yml and everything in the "data" folder, except the stuff in the "strings" folder.
|| (!string.IsNullOrWhiteSpace(x.Name) && x.FullName.Contains("data/") && !x.FullName.Contains("strings/"))
x.Name is "creds.yml" or "creds_example.yml"
|| (!string.IsNullOrWhiteSpace(x.Name) && x.FullName.Contains("data/"))
);
foreach (var zippedFile in zippedFiles)
@ -264,7 +256,7 @@ public sealed partial class EllieResolver : IBotResolver
.Prepend(Directory.GetParent(installationUri)?.FullName ?? string.Empty)
.ToArray();
await RestoreFileAsync(zippedFile, Path.Join(fileDestinationPath), cToken);
await RestoreFileAsync(zippedFile, Path.Combine(fileDestinationPath), cToken);
}
}
@ -274,10 +266,10 @@ public sealed partial class EllieResolver : IBotResolver
/// <param name="zippedFile">The file to be extracted.</param>
/// <param name="destinationPath">The final location of the extracted file.</param>
/// <param name="cToken">The cancellation token.</param>
private static async ValueTask RestoreFileAsync(ZipArchiveEntry zippedFile, string destinationPath, CancellationToken cToken = default)
private async static ValueTask RestoreFileAsync(ZipArchiveEntry zippedFile, string destinationPath, CancellationToken cToken = default)
{
await using var zipStream = zippedFile.Open();
await using var fileStream = new FileStream(destinationPath, FileMode.Create);
using var zipStream = zippedFile.Open();
using var fileStream = new FileStream(destinationPath, FileMode.Create);
await zipStream.CopyToAsync(fileStream, cToken);
}
@ -285,7 +277,7 @@ public sealed partial class EllieResolver : IBotResolver
/// <summary>
/// Gets the name of the file to be downloaded.
/// </summary>
/// <param name="version">The version of EllieBot.</param>
/// <param name="version">The version of NadekoBot.</param>
/// <returns>The name of the file to download.</returns>
/// <exception cref="NotSupportedException">Occurs when this method is executed in an unsupported platform.</exception>
private static string GetDownloadFileName(string version)
@ -303,120 +295,10 @@ public sealed partial class EllieResolver : IBotResolver
// 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 EllieBot on this OS.")
_ => throw new NotSupportedException($"Architecture of type {RuntimeInformation.OSArchitecture} is not supported by NadekoBot on this OS.")
};
}
/// <summary>
/// Gets the download url to the latest bot release.
/// </summary>
/// <param name="latestVersion">The latest version of the bot.</param>
/// <param name="cToken">The cancellation token.</param>
/// <returns>The url to the latest bot release.</returns>
private async ValueTask<string> GetDownloadUrlAsync(string latestVersion, CancellationToken cToken = default)
{
var downloadFileName = GetDownloadFileName(latestVersion);
try
{
// The first release is the most recent one.
return (await GetLatestVersionFromApiAsync(cToken)).Assets
.First(x => x.Name.Equals(downloadFileName, StringComparison.Ordinal))
.Url;
}
catch (InvalidOperationException)
{
return $"https://toastielab.dev/EllieBotDevs/elliebot/releases/download/{latestVersion}/{downloadFileName}";
}
}
/// <summary>
/// Gets the latest bot version from the Toastielab latest release URL.
/// </summary>
/// <param name="cToken">The cancellation token.</param>
/// <returns>The latest version of the bot.</returns>
/// <exception cref="InvalidOperationException">Occurs when parsing of the response fails.</exception>
private async ValueTask<string> GetLatestVersionFromUrlAsync(CancellationToken cToken = default)
{
var http = _httpClientFactory.CreateClient(AppConstants.NoRedirectClient);
var response = await http.GetAsync(_toastielabReleasesRepoUrl, cToken);
var lastSlashIndex = response.Headers.Location?.OriginalString.LastIndexOf('/')
?? throw new InvalidOperationException("Failed to get the latest EllieBot version.");
return response.Headers.Location.OriginalString[(lastSlashIndex + 1)..];
}
/// <summary>
/// Gets the latest bot version from the Toastielab API.
/// </summary>
/// <param name="cToken">The cancellation token.</param>
/// <returns>The latest version of the bot.</returns>
/// <exception cref="InvalidOperationException">Occurs when the API call fails.</exception>
private async ValueTask<ToastielabRelease> GetLatestVersionFromApiAsync(CancellationToken cToken = default)
{
if (_memoryCache.TryGetValue(_cachedCurrentVersionKey, out var cachedObject) && cachedObject is ToastielabRelease cachedResponse)
return cachedResponse;
var http = _httpClientFactory.CreateClient();
var httpResponse = await http.GetAsync(_toastielabReleasesEndpointUrl, cToken);
if (!httpResponse.IsSuccessStatusCode)
throw new InvalidOperationException("The call to the Toastielab API failed.");
var response = JsonSerializer.Deserialize<ToastielabRelease>(await httpResponse.Content.ReadAsStringAsync(cToken))
?? throw new InvalidOperationException("Failed deserializing Toastielab's response.");
_memoryCache.Set(_cachedCurrentVersionKey, response, TimeSpan.FromMinutes(1));
return response;
}
/// <summary>
/// Gets the bot version from the bot's assembly.
/// </summary>
/// <param name="executableUri">The path to the bot's executable file.</param>
/// <param name="cToken">The cancellation token.</param>
/// <returns>The version of the bot or <see langword="null"/> if the executable file is not found.</returns>
/// <exception cref="InvalidOperationException">Occurs when the assembly file is not found.</exception>
private async ValueTask<string?> GetBotVersionFromAssemblyAsync(string executableUri, CancellationToken cToken)
{
if (!File.Exists(executableUri))
return null;
var directoryUri = Directory.GetParent(executableUri)?.FullName ?? Path.GetPathRoot(executableUri)!;
var assemblyUri = Path.Join(directoryUri, "EllieBot.dll");
var isSingleFile = !File.Exists(assemblyUri);
// If Ellie is published as a single-file binary, we have to extract
// its contents first in order to read the assembly for its version.
if (isSingleFile)
{
directoryUri = Path.Join(_tempDirectory, "EllieBotExtract_" + DateTimeOffset.Now.Ticks);
assemblyUri = Path.Join(directoryUri, "EllieBot.dll");
using var executableReader = new ExecutableReader(executableUri);
await executableReader.ExtractToDirectoryAsync(directoryUri, cToken);
}
try
{
var ellieAssembly = Assembly.LoadFile(assemblyUri);
var version = ellieAssembly.GetName().Version
?? throw new InvalidOperationException($"Could not find version for the assembly at {assemblyUri}.");
var currentVersion = $"{version.Major}.{version.Minor}.{version.Build}";
await _appConfigManager.UpdateBotEntryAsync(Id, x => x with { Version = currentVersion }, cToken);
return currentVersion;
}
finally
{
if (isSingleFile)
ToastieUtilities.TryDeleteDirectory(directoryUri);
}
}
[GeneratedRegex(@"^(?:\S+\-)(\S+\-\S+)\-", RegexOptions.Compiled)]
private static partial Regex GenerateUnzipedDirRegex();
}

View file

@ -1,10 +1,9 @@
using Toastie.Utilities;
using EllieHub.Features.AppConfig.Services.Abstractions;
using EllieHub.Services.Abstractions;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Text.RegularExpressions;
namespace EllieHub.Features.AppConfig.Services;
namespace EllieHub.Services;
/// <summary>
/// Service that checks, downloads, installs, and updates ffmpeg on Linux.
@ -15,7 +14,7 @@ public sealed partial class FfmpegLinuxResolver : FfmpegResolver
{
private readonly Regex _ffmpegLatestVersionRegex = FfmpegLatestVersionRegexGenerator();
private readonly string _tempDirectory = Path.GetTempPath();
private bool _isUpdating;
private bool _isUpdating = false;
private readonly IHttpClientFactory _httpClientFactory;
/// <inheritdoc/>
@ -49,7 +48,7 @@ public sealed partial class FfmpegLinuxResolver : FfmpegResolver
}
/// <inheritdoc/>
public override async ValueTask<(string? OldVersion, string? NewVersion)> InstallOrUpdateAsync(string installationUri, CancellationToken cToken = default)
public override async ValueTask<(string? OldVersion, string? NewVersion)> InstallOrUpdateAsync(string dependenciesUri, CancellationToken cToken = default)
{
if (_isUpdating)
return (null, null);
@ -65,42 +64,42 @@ public sealed partial class FfmpegLinuxResolver : FfmpegResolver
if (currentVersion == newVersion)
return (currentVersion, null);
ToastieUtilities.TryDeleteFile(Path.Join(installationUri, FileName));
ToastieUtilities.TryDeleteFile(Path.Join(installationUri, "ffprobe"));
Utilities.TryDeleteFile(Path.Combine(dependenciesUri, FileName));
Utilities.TryDeleteFile(Path.Combine(dependenciesUri, "ffprobe"));
}
// Install
Directory.CreateDirectory(installationUri);
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();
await using var downloadStream = await http.GetStreamAsync($"https://johnvansickle.com/ffmpeg/releases/{tarFileName}", cToken);
using var downloadStream = await http.GetStreamAsync($"https://johnvansickle.com/ffmpeg/releases/{tarFileName}", cToken);
// Save tar file to the temporary directory.
var tarFilePath = Path.Join(_tempDirectory, tarFileName);
var tarExtractDir = Path.Join(_tempDirectory, $"ffmpeg-{newVersion}-{architecture}64-static");
await using (var fileStream = new FileStream(tarFilePath, FileMode.Create))
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 = ToastieUtilities.StartProcess("tar", ["xf", tarFilePath, $"--directory=\"{_tempDirectory}\""]);
using var extractProcess = Utilities.StartProcess("tar", $"xf \"{tarFilePath}\" --directory=\"{_tempDirectory}\"");
await extractProcess.WaitForExitAsync(cToken);
// Move ffmpeg to the dependencies directory.
ToastieUtilities.TryMoveFile(Path.Join(tarExtractDir, FileName), Path.Join(installationUri, FileName), true);
ToastieUtilities.TryMoveFile(Path.Join(tarExtractDir, "ffprobe"), Path.Join(installationUri, "ffprobe"), true);
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 = ToastieUtilities.StartProcess("chmod", ["+x", Path.Join(installationUri, FileName), Path.Join(installationUri, "ffprobe")]);
using var chmod = Utilities.StartProcess("chmod", $"+x \"{Path.Combine(dependenciesUri, FileName)}\" \"{Path.Combine(dependenciesUri, "ffprobe")}\"");
await chmod.WaitForExitAsync(cToken);
// Cleanup
ToastieUtilities.TryDeleteFile(tarFilePath);
ToastieUtilities.TryDeleteDirectory(tarExtractDir, true);
File.Delete(tarFilePath);
Directory.Delete(tarExtractDir, true);
// Update environment variable
ToastieUtilities.AddPathToPATHEnvar(installationUri);
Utilities.AddPathToPATHEnvar(dependenciesUri);
_isUpdating = false;
return (currentVersion, newVersion);

View file

@ -1,10 +1,9 @@
using Toastie.Utilities;
using EllieHub.Features.AppConfig.Models.Api.Evermeet;
using EllieHub.Features.AppConfig.Services.Abstractions;
using EllieHub.Models.Api;
using EllieHub.Services.Abstractions;
using System.IO.Compression;
using System.Runtime.Versioning;
namespace EllieHub.Features.AppConfig.Services;
namespace EllieHub.Services;
/// <summary>
/// Service that checks, downloads, installs, and updates ffmpeg on MacOS.
@ -16,7 +15,7 @@ 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;
private bool _isUpdating = false;
private readonly IHttpClientFactory _httpClientFactory;
/// <inheritdoc/>
@ -39,7 +38,7 @@ public sealed class FfmpegMacResolver : FfmpegResolver
}
/// <inheritdoc/>
public override async ValueTask<(string? OldVersion, string? NewVersion)> InstallOrUpdateAsync(string installationUri, CancellationToken cToken = default)
public override async ValueTask<(string? OldVersion, string? NewVersion)> InstallOrUpdateAsync(string dependenciesUri, CancellationToken cToken = default)
{
if (_isUpdating)
return (null, null);
@ -58,24 +57,24 @@ public sealed class FfmpegMacResolver : FfmpegResolver
return (currentVersion, null);
}
ToastieUtilities.TryDeleteFile(Path.Join(installationUri, FileName));
ToastieUtilities.TryDeleteFile(Path.Join(installationUri, "ffprobe"));
Utilities.TryDeleteFile(Path.Combine(dependenciesUri, FileName));
Utilities.TryDeleteFile(Path.Combine(dependenciesUri, "ffprobe"));
}
// Install
Directory.CreateDirectory(installationUri);
Directory.CreateDirectory(dependenciesUri);
var http = _httpClientFactory.CreateClient();
var ffmpegResponse = await http.CallApiAsync<EvermeetInfo>(_apiFfmpegInfoEndpoint, cToken);
var ffprobeResponse = await http.CallApiAsync<EvermeetInfo>(_apiFfprobeInfoEndpoint, cToken);
await Task.WhenAll(
InstallDependencyAsync(ffmpegResponse, installationUri, cToken),
InstallDependencyAsync(ffprobeResponse, installationUri, cToken)
InstallDependencyAsync(ffmpegResponse, dependenciesUri, cToken),
InstallDependencyAsync(ffprobeResponse, dependenciesUri, cToken)
);
// Update environment variable
ToastieUtilities.AddPathToPATHEnvar(installationUri);
Utilities.AddPathToPATHEnvar(dependenciesUri);
_isUpdating = false;
return (currentVersion, newVersion);
@ -92,26 +91,26 @@ public sealed class FfmpegMacResolver : FfmpegResolver
var http = _httpClientFactory.CreateClient();
var downloadUrl = downloadInfo.Download["zip"].Url;
var zipFileName = downloadUrl[(downloadUrl.LastIndexOf('/') + 1)..];
var zipFilePath = Path.Join(_tempDirectory, zipFileName);
var zipFilePath = Path.Combine(_tempDirectory, zipFileName);
// Download the zip file and save it to the temporary directory.
await using var zipStream = await http.GetStreamAsync(downloadUrl, cToken);
using var zipStream = await http.GetStreamAsync(downloadUrl, cToken);
await using (var fileStream = new FileStream(zipFilePath, FileMode.Create))
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.Join(dependenciesUri, downloadInfo.Name);
ToastieUtilities.TryMoveFile(Path.Join(_tempDirectory, downloadInfo.Name), finalFileUri, true);
var finalFileUri = Path.Combine(dependenciesUri, downloadInfo.Name);
File.Move(Path.Combine(_tempDirectory, downloadInfo.Name), finalFileUri, true);
// Mark binary as executable.
using var chmod = ToastieUtilities.StartProcess("chmod", $"+x \"{finalFileUri}\"");
using var chmod = Utilities.StartProcess("chmod", $"+x \"{finalFileUri}\"");
await chmod.WaitForExitAsync(cToken);
// Cleanup.
ToastieUtilities.TryDeleteFile(zipFilePath);
File.Delete(zipFilePath);
}
}

View file

@ -1,10 +1,9 @@
using Toastie.Utilities;
using EllieHub.Features.AppConfig.Services.Abstractions;
using EllieHub.Services.Abstractions;
using System.IO.Compression;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
namespace EllieHub.Features.AppConfig.Services;
namespace EllieHub.Services;
/// <summary>
/// Service that checks, downloads, installs, and updates ffmpeg on Windows.
@ -14,7 +13,7 @@ namespace EllieHub.Features.AppConfig.Services;
public sealed class FfmpegWindowsResolver : FfmpegResolver
{
private readonly string _tempDirectory = Path.GetTempPath();
private bool _isUpdating;
private bool _isUpdating = false;
private readonly IHttpClientFactory _httpClientFactory;
/// <inheritdoc />
@ -31,7 +30,7 @@ public sealed class FfmpegWindowsResolver : FfmpegResolver
public override ValueTask<bool?> CanUpdateAsync(CancellationToken cToken = default)
{
// I could not find any ARM build of ffmpeg for Windows.
return RuntimeInformation.OSArchitecture is Architecture.X64
return (RuntimeInformation.OSArchitecture is Architecture.X64)
? base.CanUpdateAsync(cToken)
: ValueTask.FromResult<bool?>(false);
}
@ -50,7 +49,7 @@ public sealed class FfmpegWindowsResolver : FfmpegResolver
}
/// <inheritdoc />
public override async ValueTask<(string? OldVersion, string? NewVersion)> InstallOrUpdateAsync(string installationUri, CancellationToken cToken = default)
public override async ValueTask<(string? OldVersion, string? NewVersion)> InstallOrUpdateAsync(string dependenciesUri, CancellationToken cToken = default)
{
if (_isUpdating)
return (null, null);
@ -69,22 +68,22 @@ public sealed class FfmpegWindowsResolver : FfmpegResolver
return (currentVersion, null);
}
ToastieUtilities.TryDeleteFile(Path.Join(installationUri, FileName));
ToastieUtilities.TryDeleteFile(Path.Join(installationUri, "ffprobe.exe"));
//ToastieUtilities.TryDeleteFile(Path.Join(dependenciesUri, "ffplay.exe"));
Utilities.TryDeleteFile(Path.Combine(dependenciesUri, FileName));
Utilities.TryDeleteFile(Path.Combine(dependenciesUri, "ffprobe.exe"));
//Utilities.TryDeleteFile(Path.Combine(dependenciesUri, "ffplay.exe"));
}
// Install
Directory.CreateDirectory(installationUri);
Directory.CreateDirectory(dependenciesUri);
var zipFileName = $"ffmpeg-{newVersion}-full_build.zip";
var http = _httpClientFactory.CreateClient();
await using var downloadStream = await http.GetStreamAsync($"https://github.com/GyanD/codexffmpeg/releases/download/{newVersion}/{zipFileName}", cToken);
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.Join(_tempDirectory, zipFileName);
var zipExtractDir = Path.Join(_tempDirectory, zipFileName[..^4]);
await using (var fileStream = new FileStream(zipFilePath, FileMode.Create))
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
@ -95,17 +94,17 @@ public sealed class FfmpegWindowsResolver : FfmpegResolver
ZipFile.ExtractToDirectory(zipFilePath, _tempDirectory);
// Move ffmpeg to the dependencies directory.
ToastieUtilities.TryMoveFile(Path.Join(zipExtractDir, "bin", FileName), Path.Join(installationUri, FileName), true);
ToastieUtilities.TryMoveFile(Path.Join(zipExtractDir, "bin", "ffprobe.exe"), Path.Join(installationUri, "ffprobe.exe"), true);
//ToastieUtilities.TryMoveFile(Path.Join(zipExtractDir, "bin", "ffplay.exe"), Path.Join(dependenciesUri, "ffplay.exe"));
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
ToastieUtilities.TryDeleteFile(zipFilePath);
ToastieUtilities.TryDeleteDirectory(zipExtractDir);
File.Delete(zipFilePath);
Directory.Delete(zipExtractDir, true);
}, cToken);
// Update environment variable
ToastieUtilities.AddPathToPATHEnvar(installationUri);
Utilities.AddPathToPATHEnvar(dependenciesUri);
_isUpdating = false;
return (currentVersion, newVersion);

View file

@ -1,19 +1,18 @@
using EllieHub.Features.AppConfig.Models;
using EllieHub.Features.BotConfig.Models;
using EllieHub.Features.BotConfig.Services.Abstractions;
using System.Collections.Concurrent;
using EllieHub.Models.Config;
using EllieHub.Models.EventArguments;
using EllieHub.Services.Abstractions;
using System.Diagnostics.CodeAnalysis;
using System.Text;
namespace EllieHub.Features.BotConfig.Services;
namespace EllieHub.Services;
/// <summary>
/// Defines a service that writes logs of bot instances to the disk.
/// </summary>
public sealed class LogWriter : ILogWriter
{
private readonly ConcurrentDictionary<Guid, StringBuilder> _botLogs = [];
private readonly ReadOnlyAppSettings _appConfig;
private readonly Dictionary<Guid, StringBuilder> _botLogs = new();
private readonly ReadOnlyAppConfig _appConfig;
/// <inheritdoc/>
public event EventHandler<ILogWriter, LogFlushEventArgs>? OnLogCreated;
@ -22,17 +21,13 @@ public sealed class LogWriter : ILogWriter
/// Creates a service that writes logs of bot instances to the disk.
/// </summary>
/// <param name="appConfig">The application settings.</param>
public LogWriter(ReadOnlyAppSettings appConfig)
public LogWriter(ReadOnlyAppConfig appConfig)
=> _appConfig = appConfig;
/// <inheritdoc/>
public async Task<bool> FlushAllAsync(bool removeFromMemory = false, CancellationToken cToken = default)
{
var result = await Task.WhenAll(_botLogs.Keys.Select(x => FlushAsync(x, false, cToken)));
if (removeFromMemory)
_botLogs.Clear();
var result = await Task.WhenAll(_botLogs.Keys.Select(x => FlushAsync(x, removeFromMemory, cToken)));
return result.Any(x => x);
}
@ -43,7 +38,7 @@ public sealed class LogWriter : ILogWriter
return false;
if (removeFromMemory)
_botLogs.Remove(botId, out _);
_botLogs.Remove(botId);
if (logStringBuilder.Length is 0)
return false;
@ -53,7 +48,7 @@ public sealed class LogWriter : ILogWriter
var botEntry = _appConfig.BotEntries[botId];
var now = DateTimeOffset.Now;
var date = new DateOnly(now.Year, now.Month, now.Day).ToShortDateString().Replace('/', '-');
var fileUri = Path.Join(_appConfig.LogsDirectoryUri, $"{botEntry.Name}_v{botEntry.Version}_{date}-{now.ToUnixTimeSeconds()}.txt");
var fileUri = Path.Combine(_appConfig.LogsDirectoryUri, $"{botEntry.Name}_v{botEntry.Version}_{date}-{now.ToUnixTimeSeconds()}.txt");
await File.WriteAllTextAsync(fileUri, logStringBuilder.ToString(), cToken);
@ -78,7 +73,7 @@ public sealed class LogWriter : ILogWriter
logStringBuilder.AppendLine(message);
if (logStringBuilder.Length > _appConfig.LogMaxSizeMb * 1_000_000)
if ((logStringBuilder.Length > _appConfig.LogMaxSizeMb * 1_000_000))
_ = FlushAsync(botId);
return true;

View file

@ -1,6 +1,6 @@
using EllieHub.Features.AppConfig.Services.Abstractions;
using EllieHub.Services.Abstractions;
namespace EllieHub.Features.AppConfig.Services.Mocks;
namespace EllieHub.Services.Mocks;
/// <summary>
/// Service that pretends to check, download, install, and update ffmpeg.

View file

@ -1,8 +1,8 @@
using EllieHub.Features.AppConfig.Models;
using EllieHub.Features.AppConfig.Services.Abstractions;
using EllieHub.Features.AppWindow.Models;
using EllieHub.Models;
using EllieHub.Models.Config;
using EllieHub.Services.Abstractions;
namespace EllieHub.Features.AppConfig.Services.Mocks;
namespace EllieHub.Services.Mocks;
/// <summary>
/// Represents a service that pretends to manage the application's settings.
@ -10,7 +10,7 @@ namespace EllieHub.Features.AppConfig.Services.Mocks;
internal sealed class MockAppConfigManager : IAppConfigManager
{
/// <inheritdoc/>
public ReadOnlyAppSettings AppConfig { get; } = new(new() { BotEntries = new() { [Guid.Empty] = new("MockBot", Path.Join(AppStatics.AppDefaultBotDirectoryUri, "MockBot"), 0) } });
public ReadOnlyAppConfig AppConfig { get; } = new(new() { BotEntries = new() { [Guid.Empty] = new("MockBot", Path.Combine(AppStatics.AppDefaultBotDirectoryUri, "MockBot"), 0) } });
/// <inheritdoc/>
public ValueTask<BotEntry> CreateBotEntryAsync(CancellationToken cToken = default)
@ -29,6 +29,6 @@ internal sealed class MockAppConfigManager : IAppConfigManager
=> ValueTask.FromResult(false);
/// <inheritdoc/>
public ValueTask UpdateConfigAsync(Action<AppSettings> action, CancellationToken cToken = default)
public ValueTask UpdateConfigAsync(Action<AppConfig> action, CancellationToken cToken = default)
=> ValueTask.CompletedTask;
}

View file

@ -1,6 +1,6 @@
using EllieHub.Features.BotConfig.Services.Abstractions;
using EllieHub.Services.Abstractions;
namespace EllieHub.Features.BotConfig.Services.Mocks;
namespace EllieHub.Services.Mocks;
/// <summary>
/// Defines a service that pretends to check, download, install, and update a bot instance.
@ -14,13 +14,10 @@ internal sealed class MockEllieResolver : IBotResolver
public Guid Id { get; } = Guid.Empty;
/// <inheritdoc/>
public string DependencyName { get; } = "EllieBot";
public string DependencyName { get; } = "NadekoBot";
/// <inheritdoc/>
public string FileName { get; } = "EllieBot";
/// <inheritdoc/>
public bool IsUpdateInProgress { get; } = false;
public string FileName { get; } = "NadekoBot";
/// <inheritdoc/>
public ValueTask<bool?> CanUpdateAsync(CancellationToken cToken = default)

View file

@ -1,9 +1,8 @@
using Toastie.Utilities;
using Microsoft.Extensions.Caching.Memory;
using EllieHub.Features.AppConfig.Services.Abstractions;
using EllieHub.Services.Abstractions;
using System.Runtime.InteropServices;
namespace EllieHub.Features.AppConfig.Services;
namespace EllieHub.Services;
/// <summary>
/// Service that checks, downloads, installs, and updates yt-dlp.
@ -14,15 +13,15 @@ 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;
private bool _isUpdating = false;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _memoryCache;
/// <inheritdoc />
public string DependencyName { get; } = "Yt-dlp";
public string DependencyName { get; } = "Youtube-dlp";
/// <inheritdoc />
public string FileName { get; } = OperatingSystem.IsWindows() ? "yt-dlp.exe" : "yt-dlp";
public string FileName { get; } = (OperatingSystem.IsWindows()) ? "yt-dlp.exe" : "yt-dlp";
/// <summary>
/// Creates a service that checks, downloads, installs, and updates yt-dlp.
@ -57,16 +56,16 @@ public sealed class YtdlpResolver : IYtdlpResolver
public async ValueTask<string?> GetCurrentVersionAsync(CancellationToken cToken = default)
{
// If yt-dlp is not accessible from the shell...
if (!ToastieUtilities.ProgramExists(_ytdlpProcessName))
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.Join(AppStatics.AppDepsUri, FileName)))
if (!File.Exists(Path.Combine(AppStatics.AppDepsUri, FileName)))
return null;
// Else, add the dependencies directory to the PATH envar,
// then try again.
ToastieUtilities.AddPathToPATHEnvar(AppStatics.AppDepsUri);
Utilities.AddPathToPATHEnvar(AppStatics.AppDepsUri);
return await GetCurrentVersionAsync(cToken);
}
@ -74,7 +73,7 @@ public sealed class YtdlpResolver : IYtdlpResolver
if (_memoryCache.TryGetValue<string>(_cachedCurrentVersionKey, out var currentVersion) && currentVersion is not null)
return currentVersion;
using var ytdlp = ToastieUtilities.StartProcess(_ytdlpProcessName, "--version", true);
using var ytdlp = Utilities.StartProcess(_ytdlpProcessName, "--version");
var currentProcessVersion = (await ytdlp.StandardOutput.ReadToEndAsync(cToken)).Trim();
_memoryCache.Set(_cachedCurrentVersionKey, currentProcessVersion, TimeSpan.FromMinutes(1.5));
@ -96,7 +95,7 @@ public sealed class YtdlpResolver : IYtdlpResolver
}
/// <inheritdoc />
public async ValueTask<(string? OldVersion, string? NewVersion)> InstallOrUpdateAsync(string installationUri, CancellationToken cToken = default)
public async ValueTask<(string? OldVersion, string? NewVersion)> InstallOrUpdateAsync(string dependenciesUri, CancellationToken cToken = default)
{
if (_isUpdating)
return (null, null);
@ -117,7 +116,7 @@ public sealed class YtdlpResolver : IYtdlpResolver
return (currentVersion, null);
}
using var ytdlp = ToastieUtilities.StartProcess(_ytdlpProcessName, "-U");
using var ytdlp = Utilities.StartProcess(_ytdlpProcessName, "-U");
await ytdlp.WaitForExitAsync(cToken);
_isUpdating = false;
@ -125,21 +124,21 @@ public sealed class YtdlpResolver : IYtdlpResolver
}
// Install
Directory.CreateDirectory(installationUri);
Directory.CreateDirectory(dependenciesUri);
var finalFilePath = Path.Join(installationUri, FileName);
var finalFilePath = Path.Combine(dependenciesUri, FileName);
var http = _httpClientFactory.CreateClient();
await using var downloadStream = await http.GetStreamAsync($"https://github.com/yt-dlp/yt-dlp/releases/download/{newVersion}/{_downloadedFileName}", cToken);
await using (var fileStream = new FileStream(finalFilePath, FileMode.Create))
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
ToastieUtilities.AddPathToPATHEnvar(installationUri);
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 = ToastieUtilities.StartProcess("chmod", ["+x", finalFilePath]);
using var chmod = Utilities.StartProcess("chmod", $"+x \"{finalFilePath}\"");
await chmod.WaitForExitAsync(cToken);
}

View file

@ -16,6 +16,7 @@
<Border Classes="circular">
<Button Classes="accent" Content="+" />
</Border>
<Button Classes="link-11" Content="HyperlinkButton" />
<TextBlock Text="Text Block" />
<TextBox Classes="console" Watermark="Fake Console" />
<TextBox Watermark="Regular Text Box" Text="Sample Text" />
@ -62,7 +63,7 @@
<Style Selector="Button.accent">
<Setter Property="FontSize" Value="28" />
<Setter Property="FontWeight" Value="Bold" />
<Setter Property="Padding" Value="13 2 13 2" />
<Setter Property="Padding" Value="12 2 12 2" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="FontFamily" Value="{StaticResource NotoSansBoldFont}" />
@ -85,6 +86,31 @@
</Style>
</Style>
<!--Button styled like a hyperlink text-->
<Style Selector="Button.link-11">
<Setter Property="Foreground" Value="{DynamicResource HyperlinkColor}" />
<Setter Property="Padding" Value="0" />
<Setter Property="Cursor" Value="Hand" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="Template">
<ControlTemplate>
<ContentPresenter Content="{TemplateBinding Content}">
<ContentPresenter.Styles>
<Style Selector="TextBlock">
<Setter Property="Foreground" Value="{TemplateBinding Foreground}"/>
<!--This should be binding to the template's FontSize, but since I'm overriding-->
<!--the main TextBlock class, this doesn't seem to work properly, so I have to set-->
<!--the value manually-->
<Setter Property="FontSize" Value="11"/>
<Setter Property="TextDecorations" Value="Underline"/>
</Style>
</ContentPresenter.Styles>
</ContentPresenter>
</ControlTemplate>
</Setter>
</Style>
<!--Circular Border-->
<Style Selector="Border.circular">
<Setter Property="ClipToBounds" Value="True" />

View file

@ -2,7 +2,7 @@ using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using Avalonia.Media;
using EllieHub.Features.Abstractions;
using EllieHub.ViewModels.Abstractions;
namespace EllieHub;
@ -21,7 +21,7 @@ public sealed class ViewLocator : IDataTemplate
{
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 = $"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 };
}
}

View file

@ -1,6 +1,6 @@
using ReactiveUI;
namespace EllieHub.Features.Abstractions;
namespace EllieHub.ViewModels.Abstractions;
/// <summary>
/// The base view-model.

View file

@ -1,6 +1,6 @@
using ReactiveUI;
namespace EllieHub.Features.Abstractions;
namespace EllieHub.ViewModels.Abstractions;
/// <summary>
/// The base view-model.

View file

@ -1,21 +1,17 @@
using Avalonia.Controls;
using Toastie.Utilities;
using MsBox.Avalonia.Enums;
using EllieHub.Enums;
using EllieHub.Features.Abstractions;
using EllieHub.Features.AppConfig.Services.Abstractions;
using EllieHub.Features.AppWindow.ViewModels;
using EllieHub.Features.AppWindow.Views.Windows;
using EllieHub.Features.BotConfig.Models;
using EllieHub.Features.BotConfig.Services.Abstractions;
using EllieHub.Features.BotConfig.Views.Controls;
using EllieHub.Features.Common.ViewModels;
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;
namespace EllieHub.Features.BotConfig.ViewModels;
namespace EllieHub.ViewModels.Controls;
/// <summary>
/// View-model for <see cref="BotConfigView"/>, the window with settings and controls for a specific bot instance.
@ -32,7 +28,6 @@ public class BotConfigViewModel : ViewModelBase<BotConfigView>, IDisposable
private readonly AppView _mainWindow;
private readonly IBotOrchestrator _botOrchestrator;
private readonly ILogWriter _logWriter;
private readonly LateralBarViewModel _lateralBarViewModel;
/// <summary>
/// Raised when the user deletes the bot instance associated with this view-model.
@ -44,6 +39,8 @@ public class BotConfigViewModel : ViewModelBase<BotConfigView>, IDisposable
/// </summary>
public event AsyncEventHandler<BotConfigViewModel, AvatarChangedEventArgs>? AvatarChanged;
/// <summary>
/// The name of the bot as defined in the settings file.
/// </summary>
@ -103,7 +100,7 @@ public class BotConfigViewModel : ViewModelBase<BotConfigView>, IDisposable
{
var sanitizedValue = value.ReplaceLineEndings(string.Empty);
DirectoryHint = $"Select the absolute path to the bot directory. For example: {Path.Join(_appConfigManager.AppConfig.BotsDirectoryUri, sanitizedValue)}";
DirectoryHint = $"Select the absolute path to the bot directory. For example: {Path.Combine(_appConfigManager.AppConfig.BotsDirectoryUri, sanitizedValue)}";
this.RaiseAndSetIfChanged(ref _botName, sanitizedValue);
}
}
@ -153,7 +150,6 @@ public class BotConfigViewModel : ViewModelBase<BotConfigView>, IDisposable
_mainWindow = mainWindow;
_botOrchestrator = botOrchestrator;
_logWriter = logWriter;
_lateralBarViewModel = mainWindow.ViewModel!.LateralBarInstance;
BotDirectoryUriBar = botDirectoryUriBar;
UpdateBar = updateBotBar;
FakeConsole = fakeConsole;
@ -166,7 +162,6 @@ public class BotConfigViewModel : ViewModelBase<BotConfigView>, IDisposable
var botEntry = _appConfigManager.AppConfig.BotEntries[botResolver.Id];
_ = LoadUpdateBarAsync(botResolver, updateBotBar);
_logWriter.TryRead(botResolver.Id, out var logContent);
FakeConsole.Content = logContent ?? string.Empty;
FakeConsole.Watermark = "Waiting for the bot to start...";
@ -175,15 +170,18 @@ public class BotConfigViewModel : ViewModelBase<BotConfigView>, IDisposable
_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(!File.Exists(Path.Join(botEntry.InstanceDirectoryUri, Resolver.FileName)), true);
EnableButtons(!Directory.Exists(botEntry.InstanceDirectoryUri), true);
_ = LoadUpdateBarAsync(botResolver, updateBotBar);
// Dispose when the view is deactivated
this.WhenActivated(disposables => Disposable.Create(Dispose).DisposeWith(disposables));
this.WhenActivated(disposables => Disposable.Create(() => Dispose()).DisposeWith(disposables));
}
/// <summary>
@ -216,12 +214,14 @@ public class BotConfigViewModel : ViewModelBase<BotConfigView>, IDisposable
Directory.Delete(BotDirectoryUriBar.CurrentUri);
}
if (!ToastieUtilities.TryMoveDirectory(oldUri, BotDirectoryUriBar.CurrentUri))
throw new InvalidOperationException(
$"Could not move \"{oldUri}\" to \"{BotDirectoryUriBar.CurrentUri}\"." +
Environment.NewLine + Environment.NewLine +
"Make sure you have permission to write to the target directory."
);
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();
}
@ -243,11 +243,6 @@ public class BotConfigViewModel : ViewModelBase<BotConfigView>, IDisposable
}
finally
{
_ = LoadUpdateBarAsync(Resolver, UpdateBar);
if (!wereButtonsUnlocked && File.Exists(Path.Join(BotDirectoryUriBar.CurrentUri, Resolver.FileName)))
EnableButtons(false, true);
else
EnableButtons(!wereButtonsUnlocked, true);
}
}
@ -311,7 +306,7 @@ public class BotConfigViewModel : ViewModelBase<BotConfigView>, IDisposable
var backupUri = await Resolver.CreateBackupAsync();
await (string.IsNullOrWhiteSpace(backupUri)
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));
@ -327,7 +322,7 @@ public class BotConfigViewModel : ViewModelBase<BotConfigView>, IDisposable
{
ButtonDefinitions = ButtonEnum.OkCancel,
ContentTitle = "Are you sure?",
ContentMessage = $"Are you sure you want to delete {ActualBotName}?{Environment.NewLine}This action cannot be reversed.",
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,
@ -341,7 +336,7 @@ public class BotConfigViewModel : ViewModelBase<BotConfigView>, IDisposable
EnableButtons(true, false);
// Stop the bot instance
_botOrchestrator.StopBot(Resolver.Id);
_botOrchestrator.Stop(Resolver.Id);
// Cleanup
FakeConsole.Content = string.Empty;
@ -371,7 +366,6 @@ public class BotConfigViewModel : ViewModelBase<BotConfigView>, IDisposable
}
EnableButtons(true, false);
_lateralBarViewModel.ToggleEnable(false);
var originalStatus = UpdateBar.Status;
UpdateBar.Status = DependencyStatus.Updating;
@ -397,10 +391,6 @@ public class BotConfigViewModel : ViewModelBase<BotConfigView>, IDisposable
await _mainWindow.ShowDialogWindowAsync($"An error occurred while updating {Resolver.DependencyName}:\n{ex.Message}", DialogType.Error, Icon.Error);
UpdateBar.Status = originalStatus;
}
finally
{
_lateralBarViewModel.ToggleEnable(true);
}
}
/// <summary>
@ -408,7 +398,7 @@ public class BotConfigViewModel : ViewModelBase<BotConfigView>, IDisposable
/// </summary>
public void StartBot()
{
IsBotRunning = _botOrchestrator.StartBot(Id);
IsBotRunning = _botOrchestrator.Start(Id);
if (IsBotRunning)
EnableButtons(true, false);
@ -418,37 +408,32 @@ public class BotConfigViewModel : ViewModelBase<BotConfigView>, IDisposable
/// Stops the bot instance associated with this view-model.
/// </summary>
public void StopBot()
=> _botOrchestrator.StopBot(Id);
=> _botOrchestrator.Stop(Id);
/// <summary>
/// Loads the bot update bar.
/// </summary>
/// <param name="botResolver">The bot resolver.</param>
/// <param name="updateBotBar">The update bar.</param>
private static async Task LoadUpdateBarAsync(IBotResolver botResolver, DependencyButtonViewModel updateBotBar)
private async static Task LoadUpdateBarAsync(IBotResolver botResolver, DependencyButtonViewModel updateBotBar)
{
updateBotBar.DependencyName = "Checking...";
updateBotBar.Status = DependencyStatus.Checking;
var currentVersion = await botResolver.GetCurrentVersionAsync();
updateBotBar.DependencyName = string.IsNullOrWhiteSpace(currentVersion)
updateBotBar.DependencyName = (string.IsNullOrWhiteSpace(currentVersion))
? "Not Installed"
: "EllieBot v" + currentVersion;
var canUpdate = await botResolver.CanUpdateAsync();
updateBotBar.Status = canUpdate switch
{
true => DependencyStatus.Update,
false => DependencyStatus.Installed,
null when botResolver.IsUpdateInProgress => DependencyStatus.Updating,
null => DependencyStatus.Install
};
updateBotBar.Status = (canUpdate is true)
? DependencyStatus.Update
: (canUpdate is null)
? DependencyStatus.Install
: DependencyStatus.Installed;
}
/// <summary>
/// Locks or unlocks the settings buttons of this view-model.
/// </summary>
/// <param name="lockButtons">Whether the setting buttons should be locked.</param>
/// <param name="lockButtons">Whether the settings buttons should be locked.</param>
/// <param name="isIdle">Whether this view-model is currently undergoing an operation.</param>
private void EnableButtons(bool lockButtons, bool isIdle)
{
@ -466,8 +451,10 @@ public class BotConfigViewModel : ViewModelBase<BotConfigView>, IDisposable
if (eventArgs.Id != Resolver.Id)
return;
FakeConsole.Content = FakeConsole.Content.Length > 100_000
? FakeConsole.Content[FakeConsole.Content.IndexOf(Environment.NewLine, 60_000, StringComparison.Ordinal)..] + eventArgs.Output + Environment.NewLine
_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;
}
@ -481,11 +468,14 @@ public class BotConfigViewModel : ViewModelBase<BotConfigView>, IDisposable
if (eventArgs.Id != Resolver.Id)
return;
FakeConsole.Content += eventArgs.Message;
var message = Environment.NewLine + ActualBotName + " stopped." + Environment.NewLine;
_logWriter.TryAdd(Resolver.Id, message);
FakeConsole.Content += message;
}
/// <summary>
/// Re-enables the buttons when the bot instance associated with this view-model exits.
/// Reenables the buttons when the bot instance associated with this view-model exits.
/// </summary>
/// <param name="botOrchestrator">The bot orchestrator.</param>
/// <param name="eventArgs">The event arguments.</param>
@ -501,13 +491,7 @@ public class BotConfigViewModel : ViewModelBase<BotConfigView>, IDisposable
/// <inheritdoc/>
public void Dispose()
{
UpdateBar.Click -= InstallOrUpdateAsync;
_botOrchestrator.OnStdout -= WriteLog;
_botOrchestrator.OnStderr -= WriteLog;
_botOrchestrator.OnBotExit -= LogBotExit;
_botOrchestrator.OnBotExit -= ReenableButtonsOnBotExit;
BotAvatar.Dispose();
BotAvatar?.Dispose();
GC.SuppressFinalize(this);
}
}

View file

@ -2,18 +2,15 @@ using Avalonia.Controls;
using Avalonia.Styling;
using MsBox.Avalonia.Enums;
using EllieHub.Enums;
using EllieHub.Features.Abstractions;
using EllieHub.Features.AppConfig.Services.Abstractions;
using EllieHub.Features.AppConfig.Views.Controls;
using EllieHub.Features.AppConfig.Views.Windows;
using EllieHub.Features.AppWindow.ViewModels;
using EllieHub.Features.AppWindow.Views.Windows;
using EllieHub.Features.Common.Services.Abstractions;
using EllieHub.Features.Common.ViewModels;
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.Features.AppConfig.ViewModels;
namespace EllieHub.ViewModels.Controls;
/// <summary>
/// The view-model for the application's settings.
@ -28,7 +25,6 @@ public class ConfigViewModel : ViewModelBase<ConfigView>
private readonly IAppConfigManager _appConfigManager;
private readonly AboutMeViewModel _aboutMeViewModel;
private readonly AppView _mainWindow;
private readonly LateralBarViewModel _lateralBarViewModel;
private double _maxLogSize;
private int _selectedThemeIndex;
@ -99,7 +95,6 @@ public class ConfigViewModel : ViewModelBase<ConfigView>
_maxLogSize = _appConfigManager.AppConfig.LogMaxSizeMb;
_selectedThemeIndex = (int)_appConfigManager.AppConfig.Theme;
_aboutMeViewModel = aboutMeViewModel;
_lateralBarViewModel = mainWindow.ViewModel!.LateralBarInstance;
BotsUriBar = botsUriBar;
BotsUriBar.CurrentUri = appConfigManager.AppConfig.BotsDirectoryUri;
@ -153,7 +148,7 @@ public class ConfigViewModel : ViewModelBase<ConfigView>
if (MaxLogSize is 0.0 && spinDirection is SpinDirection.Decrease)
return;
MaxLogSize = spinDirection is SpinDirection.Increase
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);
@ -201,12 +196,11 @@ public class ConfigViewModel : ViewModelBase<ConfigView>
dependencyButton.Click += async (buttonViewModel, _) => await HandleDependencyAsync(buttonViewModel, dependencyResolver);
var canUpdate = await dependencyResolver.CanUpdateAsync();
dependencyButton.Status = canUpdate switch
{
true => DependencyStatus.Update,
false => DependencyStatus.Installed,
null => DependencyStatus.Install
};
dependencyButton.Status = (canUpdate is null)
? DependencyStatus.Install
: (canUpdate is true)
? DependencyStatus.Update
: DependencyStatus.Installed;
}
/// <summary>
@ -219,7 +213,6 @@ public class ConfigViewModel : ViewModelBase<ConfigView>
/// </exception>
private async ValueTask HandleDependencyAsync(DependencyButtonViewModel buttonViewModel, IDependencyResolver dependencyResolver)
{
_lateralBarViewModel.ToggleEnable(false);
var originalStatus = buttonViewModel.Status;
buttonViewModel.Status = DependencyStatus.Updating;
@ -241,9 +234,5 @@ public class ConfigViewModel : ViewModelBase<ConfigView>
await _mainWindow.ShowDialogWindowAsync($"An error occurred while updating {dependencyResolver.DependencyName}:\n{ex.Message}", DialogType.Error, Icon.Error);
buttonViewModel.Status = originalStatus;
}
finally
{
_lateralBarViewModel.ToggleEnable(true);
}
}
}

View file

@ -1,12 +1,12 @@
using Avalonia.Media.Immutable;
using EllieHub.Enums;
using EllieHub.Features.Abstractions;
using EllieHub.Features.AppWindow.Views.Windows;
using EllieHub.Features.Common.Views.Controls;
using EllieHub.ViewModels.Abstractions;
using EllieHub.Views.Controls;
using EllieHub.Views.Windows;
using ReactiveUI;
using System.Diagnostics;
namespace EllieHub.Features.Common.ViewModels;
namespace EllieHub.ViewModels.Controls;
/// <summary>
/// Defines the view-model for a button that installs a dependency for Ellie.

View file

@ -1,8 +1,8 @@
using EllieHub.Features.Abstractions;
using EllieHub.Features.BotConfig.Views.Controls;
using EllieHub.ViewModels.Abstractions;
using EllieHub.Views.Controls;
using ReactiveUI;
namespace EllieHub.Features.BotConfig.ViewModels;
namespace EllieHub.ViewModels.Controls;
/// <summary>
/// View-model for <see cref="FakeConsole"/>, the fake console that displays text.

View file

@ -1,8 +1,8 @@
using EllieHub.Features.Abstractions;
using EllieHub.Features.Home.Views.Controls;
using EllieHub.ViewModels.Abstractions;
using EllieHub.Views.Controls;
using System.Diagnostics;
namespace EllieHub.Features.Home.ViewModels;
namespace EllieHub.ViewModels.Controls;
/// <summary>
/// View-model for the home window, with links to Ellie resources.

View file

@ -1,35 +1,25 @@
using Avalonia.Controls;
using EllieHub.Features.Abstractions;
using EllieHub.Features.AppConfig.Services.Abstractions;
using EllieHub.Features.AppWindow.Models;
using EllieHub.Features.AppWindow.Views.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.Features.AppWindow.ViewModels;
namespace EllieHub.ViewModels.Controls;
/// <summary>
/// View-model for <see cref="LateralBarView"/>, the lateral bar with home, bot, and configuration buttons.
/// </summary>
public class LateralBarViewModel : ViewModelBase<LateralBarView>
{
private bool _isLateralBarEnabled = true;
private readonly IAppConfigManager _appConfigManager;
/// <summary>
/// Collection of buttons for bot instances.
/// </summary>
public ObservableCollection<Button> BotButtonList { get; } = [];
public ObservableCollection<Button> BotButtonList { get; } = new();
/// <summary>
/// Determines whether the buttons on the lateral bar are enabled or not.
/// </summary>
public bool IsLateralBarEnabled
{
get => _isLateralBarEnabled;
private set => this.RaiseAndSetIfChanged(ref _isLateralBarEnabled, value);
}
private readonly IAppConfigManager _appConfigManager;
/// <summary>
/// Creates the view-model for the <see cref="LateralBarView"/>.
@ -77,23 +67,11 @@ public class LateralBarViewModel : ViewModelBase<LateralBarView>
this.RaisePropertyChanged(nameof(BotButtonList));
}
/// <summary>
/// Enables or disables the buttons on the lateral bar.
/// </summary>
/// <param name="enable"><see langword="true"/> to enable the buttons, <see langword="false"/> otherwise.</param>
public void ToggleEnable(bool enable)
{
IsLateralBarEnabled = enable;
foreach (var button in BotButtonList)
button.IsEnabled = enable;
}
/// <summary>
/// Loads the bot buttons to the lateral bar.
/// </summary>
/// <param name="botEntires">The bot entries.</param>
public void ReloadBotButtons(IReadOnlyDictionary<Guid, BotInstanceInfo> botEntires)
private void ReloadBotButtons(IReadOnlyDictionary<Guid, BotInstanceInfo> botEntires)
{
BotButtonList.Clear();

View file

@ -1,12 +1,11 @@
using Avalonia.Platform.Storage;
using Toastie.Utilities;
using EllieHub.Features.Abstractions;
using EllieHub.Features.Common.Models;
using EllieHub.Features.Common.Views.Controls;
using EllieHub.Models.EventArguments;
using EllieHub.ViewModels.Abstractions;
using EllieHub.Views.Controls;
using ReactiveUI;
using System.Diagnostics;
namespace EllieHub.Features.Common.ViewModels;
namespace EllieHub.ViewModels.Controls;
/// <summary>
/// Represents a text box for inputting the absolute path of a directory.
@ -15,7 +14,7 @@ public class UriInputBarViewModel : ViewModelBase<UriInputBar>
{
private static readonly FolderPickerOpenOptions _folderPickerOptions = new();
private string _lastValidUri = AppStatics.AppDefaultConfigDirectoryUri;
private bool _isDirectoryValid;
private bool _isDirectoryValid = false;
private string _currentUri = string.Empty;
private readonly IStorageProvider _storageProvider;
@ -93,5 +92,5 @@ public class UriInputBarViewModel : ViewModelBase<UriInputBar>
/// <param name="directoryUri">The absolute path to a directory.</param>
/// <returns><see langword="true"/> if the directory is valid, <see langword="false"/> otherwise.</returns>
private bool IsValidDirectory(string directoryUri)
=> Directory.Exists(directoryUri) && ToastieUtilities.HasWritePermissionAt(directoryUri);
=> Directory.Exists(directoryUri) && Utilities.CanWriteTo(directoryUri);
}

View file

@ -1,8 +1,8 @@
using EllieHub.Features.Abstractions;
using EllieHub.Features.AppConfig.Views.Windows;
using EllieHub.ViewModels.Abstractions;
using EllieHub.Views.Windows;
using System.Diagnostics;
namespace EllieHub.Features.AppConfig.ViewModels;
namespace EllieHub.ViewModels.Windows;
/// <summary>
/// View-model for the about me dialog window.

View file

@ -1,10 +1,10 @@
using EllieHub.Features.Abstractions;
using EllieHub.Features.AppWindow.Views.Controls;
using EllieHub.Features.AppWindow.Views.Windows;
using EllieHub.Features.Home.ViewModels;
using EllieHub.ViewModels.Abstractions;
using EllieHub.ViewModels.Controls;
using EllieHub.Views.Controls;
using EllieHub.Views.Windows;
using ReactiveUI;
namespace EllieHub.Features.AppWindow.ViewModels;
namespace EllieHub.ViewModels.Windows;
/// <summary>
/// View-model for the main window.

View file

@ -1,7 +1,6 @@
using EllieHub.Features.Abstractions;
using EllieHub.Features.Home.Views.Windows;
using EllieHub.ViewModels.Abstractions;
namespace EllieHub.Features.Home.ViewModels;
namespace EllieHub.ViewModels.Windows;
/// <summary>
/// View-model for the update dialog window.

View file

@ -3,13 +3,13 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:siv="https://github.com/kekyo/SkiaImageView"
xmlns:vm="using:EllieHub.Features.BotConfig.ViewModels"
xmlns:dd="using:EllieHub.Avalonia.DesignData.Controls"
xmlns:vm="using:EllieHub.ViewModels.Controls"
xmlns:dd="using:EllieHub.DesignData.Controls"
xmlns:const="using:EllieHub.Common"
mc:Ignorable="d"
d:DesignWidth="{x:Static const:WindowConstants.MinWindowWidth}"
d:DesignHeight="{x:Static const:WindowConstants.MinWindowHeight}"
x:Class="EllieHub.Features.BotConfig.Views.Controls.BotConfigView"
x:Class="EllieHub.Views.Controls.BotConfigView"
x:DataType="vm:BotConfigViewModel">
<Design.DataContext>
@ -22,7 +22,7 @@
Fill="{DynamicResource HeavyBackground}"/>
<Rectangle Grid.Row="1"
Grid.RowSpan="2"
Grid.RowSpan="3"
Fill="{DynamicResource MediumBackground}"/>
<!--Bot Settings Title-->

View file

@ -1,9 +1,9 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.ReactiveUI;
using EllieHub.Features.BotConfig.ViewModels;
using EllieHub.ViewModels.Controls;
namespace EllieHub.Features.BotConfig.Views.Controls;
namespace EllieHub.Views.Controls;
/// <summary>
/// Represents the view with settings and controls for a specific bot instance.
@ -26,7 +26,7 @@ public partial class BotConfigView : ReactiveUserControl<BotConfigViewModel>
/// <param name="eventArgs">The event arguments.</param>
private void AvatarButtonHover(object? sender, PointerEventArgs eventArgs)
{
if (sender is not Button button)
if (!Utilities.TryCastTo<Button>(sender, out var button))
return;
button.Opacity = 3.0;
@ -40,7 +40,7 @@ public partial class BotConfigView : ReactiveUserControl<BotConfigViewModel>
/// <param name="eventArgs">The event arguments.</param>
private void AvatarButtonUnhover(object? sender, PointerEventArgs eventArgs)
{
if (sender is not Button button)
if (!Utilities.TryCastTo<Button>(sender, out var button))
return;
button.Opacity = 0.0;

View file

@ -2,14 +2,14 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:EllieHub.Features.AppConfig.ViewModels"
xmlns:views="using:EllieHub.Features.Common.Views.Controls"
xmlns:vm="using:EllieHub.ViewModels.Controls"
xmlns:views="using:EllieHub.Views.Controls"
xmlns:const="using:EllieHub.Common"
xmlns:dd="using:EllieHub.Avalonia.DesignData.Controls"
xmlns:dd="using:EllieHub.DesignData.Controls"
mc:Ignorable="d"
d:DesignWidth="{x:Static const:WindowConstants.MinWindowWidth}"
d:DesignHeight="{x:Static const:WindowConstants.DefaultWindowHeight}"
x:Class="EllieHub.Features.AppConfig.Views.Controls.ConfigView"
x:Class="EllieHub.Views.Controls.ConfigView"
x:DataType="vm:ConfigViewModel">
<Design.DataContext>
@ -23,6 +23,7 @@
Fill="{DynamicResource HeavyBackground}" />
<Rectangle Grid.Row="1"
Grid.RowSpan="2"
Fill="{DynamicResource MediumBackground}"/>
<!--Settings Title-->
@ -38,7 +39,7 @@
<TextBlock Text="Dependencies"
FontSize="22"/>
<TextBlock Text="Make sure the dependencies below are installed if you want to use EllieBot to play music."/>
<TextBlock Text="Make sure the dependencies below are installed if you want to use Ellie to play music."/>
<!--Dependency Buttons-->
<ItemsRepeater ItemsSource="{Binding DependencyButtons}">
@ -86,10 +87,9 @@
<!--Backup Directory Bar-->
<TextBlock Grid.Row="2"
Grid.Column="0"
Margin="0 8 0 0"
Text="Backup Directory:"
ToolTip.Tip="Defines the directory the updater is going to store the backups of the bot instances."/>
ToolTip.Tip="Defines the directory the updater is going to store the backup of the bot instances."/>
<TextBox Grid.Row="3"
Grid.Column="0"
@ -113,7 +113,6 @@
<!--Logs directory Bar-->
<TextBlock Grid.Row="4"
Grid.Column="0"
Margin="0 8 0 0"
Text="Logs Directory:"
ToolTip.Tip="Defines the directory the updater is going to store the logs of the bot instances."/>

View file

@ -1,8 +1,8 @@
using Avalonia.Controls;
using Avalonia.ReactiveUI;
using EllieHub.Features.AppConfig.ViewModels;
using EllieHub.ViewModels.Controls;
namespace EllieHub.Features.AppConfig.Views.Controls;
namespace EllieHub.Views.Controls;
/// <summary>
/// The view for the application's settings.

View file

@ -2,10 +2,10 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:EllieHub.Features.Common.ViewModels"
xmlns:dd="using:EllieHub.Avalonia.DesignData.Controls"
xmlns:vm="using:EllieHub.ViewModels.Controls"
xmlns:dd="using:EllieHub.DesignData.Controls"
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="50"
x:Class="EllieHub.Features.Common.Views.Controls.DependencyButton"
x:Class="EllieHub.Views.Controls.DependencyButton"
x:DataType="vm:DependencyButtonViewModel">
<Design.DataContext>

View file

@ -1,7 +1,7 @@
using Avalonia.ReactiveUI;
using EllieHub.Features.Common.ViewModels;
using EllieHub.ViewModels.Controls;
namespace EllieHub.Features.Common.Views.Controls;
namespace EllieHub.Views.Controls;
/// <summary>
/// Represents a button that installs a dependency for Ellie.

View file

@ -2,10 +2,10 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:EllieHub.Features.BotConfig.ViewModels"
xmlns:dd="using:EllieHub.Avalonia.DesignData.Controls"
xmlns:vm="using:EllieHub.ViewModels.Controls"
xmlns:dd="using:EllieHub.DesignData.Controls"
mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="250"
x:Class="EllieHub.Features.BotConfig.Views.Controls.FakeConsole"
x:Class="EllieHub.Views.Controls.FakeConsole"
x:DataType="vm:FakeConsoleViewModel">
<Design.DataContext>
<dd:DesignFakeConsoleViewModel/>

View file

@ -1,7 +1,7 @@
using Avalonia.ReactiveUI;
using EllieHub.Features.BotConfig.ViewModels;
using EllieHub.ViewModels.Controls;
namespace EllieHub.Features.BotConfig.Views.Controls;
namespace EllieHub.Views.Controls;
/// <summary>
/// Represents a control that mimics the appearance of a terminal emulator.

View file

@ -2,13 +2,13 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:EllieHub.Features.Home.ViewModels"
xmlns:vm="using:EllieHub.ViewModels.Controls"
xmlns:const="using:EllieHub.Common"
xmlns:dd="using:EllieHub.Avalonia.DesignData.Controls"
xmlns:dd="using:EllieHub.DesignData.Controls"
mc:Ignorable="d"
d:DesignWidth="{x:Static const:WindowConstants.MinWindowWidth}"
d:DesignHeight="{x:Static const:WindowConstants.MinWindowHeight}"
x:Class="EllieHub.Features.Home.Views.Controls.HomeView"
x:Class="EllieHub.Views.Controls.HomeView"
x:DataType="vm:HomeViewModel">
<Design.DataContext>
<dd:DesignHomeViewModel/>
@ -28,18 +28,18 @@
Grid.ColumnSpan="5"
Fill="{DynamicResource HeavyBackground}"/>
<Rectangle Grid.Row="4"
<Rectangle Grid.Row="5"
Grid.Column="0"
Fill="#008DDA"/>
<Rectangle Grid.Row="4"
<Rectangle Grid.Row="5"
Grid.Column="1"
Fill="#F86752"/>
<Rectangle Grid.Row="4"
<Rectangle Grid.Row="5"
Grid.Column="2"
Grid.ColumnSpan="3"
Fill="#0e3f88"/>
Fill="#B90060"/>
<!--Top Banner-->
<Border Classes="circular"
@ -64,7 +64,7 @@
Classes="transparent"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
CommandParameter="https://commands.elliebot.net/"
CommandParameter="https://commands.elliebot.net"
Command="{Binding OpenUrl}">
<StackPanel Orientation="Horizontal">
<Image Classes="icon"
@ -78,7 +78,7 @@
Classes="transparent"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
CommandParameter="https://app.feedbacky.net/b/ellie-feedback"
CommandParameter="https://beta.elliebot.net/"
Command="{Binding OpenUrl}">
<StackPanel Orientation="Horizontal">
<Image Classes="icon"
@ -92,7 +92,7 @@
Classes="transparent"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
CommandParameter="https://docs.elliebot.net/ellie/"
CommandParameter="https://docs.elliebot.net"
Command="{Binding OpenUrl}">
<StackPanel Orientation="Horizontal">
<Image Classes="icon"
@ -106,7 +106,7 @@
Classes="transparent"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
CommandParameter="https://discord.gg/etQdZxSyEH/"
CommandParameter="https://discord.elliebot.net/"
Command="{Binding OpenUrl}">
<StackPanel Orientation="Horizontal">
<Image Classes="icon"
@ -147,7 +147,7 @@
VerticalAlignment="Top"/>
<!--Bottom Buttons-->
<Button Grid.Row="4"
<Button Grid.Row="5"
Grid.Column="0"
Classes="transparent"
Content="Buy me a Ko-fi"
@ -158,7 +158,7 @@
CommandParameter="https://ko-fi.com/toastie_t0ast"
Command="{Binding OpenUrl}"/>
<Image Grid.Row="4"
<Image Grid.Row="5"
Grid.Column="0"
Classes="icon"
MaxHeight="30"
@ -167,18 +167,18 @@
Margin="0 0 3 0"
Source="{DynamicResource KofiIcon}"/>
<Button Grid.Row="4"
<Button Grid.Row="5"
Grid.Column="1"
Classes="transparent"
Content="Support EllieBot"
Content="Support Ellie"
Foreground="White"
FontSize="12"
Cursor="Hand"
HorizontalAlignment="Stretch"
CommandParameter="https://patreon.com/toastiet0ast"
CommandParameter="https://patreon.com/emotionchild"
Command="{Binding OpenUrl}"/>
<Image Grid.Row="4"
<Image Grid.Row="5"
Grid.Column="1"
Classes="icon"
MaxHeight="30"
@ -187,7 +187,7 @@
Margin="0 0 3 0"
Source="{DynamicResource PatreonIcon}"/>
<TextBlock Grid.Row="4"
<TextBlock Grid.Row="5"
Grid.Column="2"
Grid.ColumnSpan="3"
Text="Thank You ♡"

View file

@ -1,7 +1,7 @@
using Avalonia.ReactiveUI;
using EllieHub.Features.Home.ViewModels;
using EllieHub.ViewModels.Controls;
namespace EllieHub.Features.Home.Views.Controls;
namespace EllieHub.Views.Controls;
/// <summary>
/// View for the home window, with buttons linking to official Ellie resources.

View file

@ -3,10 +3,11 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:siv="https://github.com/kekyo/SkiaImageView"
xmlns:vm="using:EllieHub.Features.AppWindow.ViewModels"
xmlns:dd="using:EllieHub.Avalonia.DesignData.Controls"
xmlns:vm="using:EllieHub.ViewModels.Controls"
xmlns:dd="using:EllieHub.DesignData.Controls"
xmlns:const="using:EllieHub.Common"
mc:Ignorable="d" d:DesignWidth="70" d:DesignHeight="450"
x:Class="EllieHub.Features.AppWindow.Views.Controls.LateralBarView"
x:Class="EllieHub.Views.Controls.LateralBarView"
x:DataType="vm:LateralBarViewModel">
<Design.DataContext>
@ -19,8 +20,7 @@
<Button Classes="transparent"
Name="HomeButton"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
IsEnabled="{Binding IsLateralBarEnabled}">
HorizontalContentAlignment="Center">
<Image Classes="icon"
Source="{DynamicResource HomeIcon}"/>
</Button>
@ -34,8 +34,7 @@
<Button Classes="accent"
Content="+"
Cursor="Hand"
Command="{Binding AddBotButtonAsync}"
IsEnabled="{Binding IsLateralBarEnabled}"/>
Command="{Binding AddBotButtonAsync}"/>
</Border>
</StackPanel>
@ -78,8 +77,7 @@
Classes="transparent"
Name="ConfigButton"
HorizontalAlignment="Stretch"
VerticalAlignment="Center"
IsEnabled="{Binding IsLateralBarEnabled}">
VerticalAlignment="Center">
<Image MaxHeight="17"
Source="{DynamicResource ConfigIcon}"/>
</Button>

View file

@ -5,22 +5,24 @@ using Avalonia.Interactivity;
using Avalonia.Media.Immutable;
using Avalonia.ReactiveUI;
using Microsoft.Extensions.DependencyInjection;
using EllieHub.Avalonia.DesignData.Common;
using EllieHub.Features.AppConfig.Models;
using EllieHub.Features.AppConfig.Services.Mocks;
using EllieHub.Features.AppWindow.ViewModels;
using EllieHub.Features.BotConfig.Models;
using EllieHub.DesignData.Common;
using EllieHub.Models.Config;
using EllieHub.Models.EventArguments;
using EllieHub.Services.Mocks;
using EllieHub.ViewModels.Controls;
using SkiaImageView;
using SkiaSharp;
namespace EllieHub.Features.AppWindow.Views.Controls;
namespace EllieHub.Views.Controls;
/// <summary>
/// View for the lateral bar with home, bot, and configuration buttons.
/// </summary>
public partial class LateralBarView : ReactiveUserControl<LateralBarViewModel>
{
private readonly ReadOnlyAppSettings _appConfig;
private static readonly Cursor _pointingHandCursor = new(StandardCursorType.Hand);
private static readonly Cursor _arrow = new(StandardCursorType.Arrow);
private readonly ReadOnlyAppConfig _appConfig;
/// <summary>
/// Raised when the user clicks a bot button.
@ -39,7 +41,7 @@ public partial class LateralBarView : ReactiveUserControl<LateralBarViewModel>
/// Creates the lateral bar of the application.
/// </summary>
/// <param name="appConfig">The application settings.</param>
public LateralBarView(ReadOnlyAppSettings appConfig)
public LateralBarView(ReadOnlyAppConfig appConfig)
{
_appConfig = appConfig;
InitializeComponent();
@ -67,10 +69,10 @@ public partial class LateralBarView : ReactiveUserControl<LateralBarViewModel>
/// <exception cref="InvalidOperationException">Occurs when the visual tree has an unexpected structure.</exception>
public void ApplyBotButtonBorder(Button button)
{
if (button.Parent?.Parent is not Border border)
if (!Utilities.TryCastTo<Border>(button.Parent?.Parent, out var border))
throw new InvalidOperationException("Visual tree has an unexpected structure.");
if (this.FindResource(base.ActualThemeVariant, "BotSelectionColor") is not ImmutableSolidColorBrush resourceColor)
if (!Utilities.TryCastTo<ImmutableSolidColorBrush>(this.FindResource(base.ActualThemeVariant, "BotSelectionColor"), out var resourceColor))
return;
border.BorderBrush = resourceColor;
@ -91,11 +93,7 @@ public partial class LateralBarView : ReactiveUserControl<LateralBarViewModel>
/// <param name="sender">The bot button that was clicked.</param>
/// <param name="eventArgs">The event arguments.</param>
private void LoadBotViewModel(object sender, RoutedEventArgs eventArgs)
{
// "sender", for some reason, is not one of the buttons stored in the lateral bar's view-model.
if (sender is Button button && this.ViewModel!.BotButtonList.First(x => x.Content == button.Content).IsEnabled)
BotButtonClick?.Invoke(button, eventArgs);
}
=> BotButtonClick?.Invoke((Button)sender, eventArgs);
/// <summary>
/// Loads the bot avatar when the buttons on the lateral bar are rendered.
@ -106,11 +104,10 @@ public partial class LateralBarView : ReactiveUserControl<LateralBarViewModel>
/// <exception cref="InvalidOperationException">Occurs when the visual tree has an unexpected structure.</exception>
private void OnBotButtonLoad(object? sender, VisualTreeAttachmentEventArgs eventArgs)
{
if (sender is not Panel panel
|| panel.Children[0] is not Border border
|| border.Child is not SKImageView botAvatar
|| panel.Children[1] is not Button button
|| button.Content is not Guid botId)
if (!Utilities.TryCastTo<Panel>(sender, out var panel)
|| !Utilities.TryCastTo<SKImageView>(((Border)panel.Children[0]).Child, out var botAvatar)
|| !Utilities.TryCastTo<Button>(panel.Children[1], out var button)
|| !Utilities.TryCastTo<Guid>(button.Content, out var botId))
throw new InvalidOperationException("Visual tree has an unexpected structure.");
// Set the avatar
@ -131,7 +128,7 @@ public partial class LateralBarView : ReactiveUserControl<LateralBarViewModel>
/// <exception cref="InvalidOperationException">Occurs when <paramref name="sender"/> is not a <see cref="Button"/>.</exception>
private void DownsizeBotAvatar(object? sender, PointerPressedEventArgs eventArgs)
{
if (sender is not Button button)
if (!Utilities.TryCastTo<Button>(sender, out var button))
throw new InvalidOperationException($"Sender is not a {nameof(Button)}.");
var botAvatar = FindAvatarComponent(button);
@ -148,7 +145,7 @@ public partial class LateralBarView : ReactiveUserControl<LateralBarViewModel>
/// <exception cref="InvalidOperationException">Occurs when <paramref name="sender"/> is not a <see cref="Button"/>.</exception>
private void UpsizeBotAvatar(object? sender, PointerReleasedEventArgs eventArgs)
{
if (sender is not Button button)
if (!Utilities.TryCastTo<Button>(sender, out var button))
throw new InvalidOperationException($"Sender is not a {nameof(Button)}.");
var botAvatar = FindAvatarComponent(button);
@ -166,7 +163,7 @@ public partial class LateralBarView : ReactiveUserControl<LateralBarViewModel>
/// <exception cref="InvalidOperationException">Occurs when the component's content is not a Guid.</exception>
private SKImageView FindAvatarComponent<T>(T component) where T : ContentControl
{
return (component.Content is not Guid botId)
return (!Utilities.TryCastTo<Guid>(component.Content, out var botId))
? throw new InvalidOperationException($"{nameof(T)} does not contain a bot Id.")
: FindAvatarComponent(botId);
}

View file

@ -2,10 +2,10 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:EllieHub.Features.Common.ViewModels"
xmlns:dd="using:EllieHub.Avalonia.DesignData.Controls"
xmlns:vm="using:EllieHub.ViewModels.Controls"
xmlns:dd="using:EllieHub.DesignData.Controls"
mc:Ignorable="d" d:DesignWidth="700" d:DesignHeight="33"
x:Class="EllieHub.Features.Common.Views.Controls.UriInputBar"
x:Class="EllieHub.Views.Controls.UriInputBar"
x:DataType="vm:UriInputBarViewModel">
<Design.DataContext>

View file

@ -1,7 +1,7 @@
using Avalonia.ReactiveUI;
using EllieHub.Features.Common.ViewModels;
using EllieHub.ViewModels.Controls;
namespace EllieHub.Features.Common.Views.Controls;
namespace EllieHub.Views.Controls;
/// <summary>
/// Represents a text box that receives the absolute path to a directory.

View file

@ -2,11 +2,12 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:EllieHub.Features.AppConfig.ViewModels"
xmlns:dd="using:EllieHub.Avalonia.DesignData.Windows"
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="305"
Width="400" Height="305"
x:Class="EllieHub.Features.AppConfig.Views.Windows.AboutMeView"
xmlns:vm="using:EllieHub.ViewModels.Windows"
xmlns:const="using:EllieHub.Common"
xmlns:dd="using:EllieHub.DesignData.Windows"
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="325"
Width="400" Height="325"
x:Class="EllieHub.Views.Windows.AboutMeView"
x:DataType="vm:AboutMeViewModel"
Title="About EllieHub"
Icon="{DynamicResource EllieHubIcon}"
@ -17,12 +18,12 @@
</Design.DataContext>
<StackPanel Margin="10 20 10 20">
<TextBlock Text="EllieBot is a general purpose open-source Discord bot created by Toastie. Support the project!"
<TextBlock Text="Ellie is a general purpose open-source Discord bot created by Toastie. Support the project!"
TextAlignment="Center"
Margin="0 0 0 10"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Button CommandParameter="https://paypal.me/toastiet0ast"
<Button CommandParameter="https://www.paypal.com/paypalme/toastiet0ast"
Command="{Binding OpenUrl}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="Paypal " />
@ -41,7 +42,7 @@
</StackPanel>
</Button>
<Button CommandParameter="https://www.patreon.com/toastiet0ast"
<Button CommandParameter="https://www.patreon.com/emotionchild"
Command="{Binding OpenUrl}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="Patreon " />
@ -54,13 +55,10 @@
<Separator Margin="20"
HorizontalAlignment="Stretch" />
<TextBlock Text="This tool was made by Toastie. If it has been useful to you, consider showing your support."
TextAlignment="Center"
<TextBlock Text="This tool was made by Toastie. If it has been useful to you, consider showing your support by buying me a coffee."
Margin="0 0 0 10" />
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Button Margin="0 0 5 0"
HorizontalAlignment="Center"
<Button HorizontalAlignment="Center"
CommandParameter="https://ko-fi.com/toastie_t0ast"
Command="{Binding OpenUrl}">
<StackPanel Orientation="Horizontal">
@ -69,26 +67,21 @@
Source="{DynamicResource UrlIcon}" />
</StackPanel>
</Button>
<Button Margin="5 0 0 0"
HorizontalAlignment="Center"
CommandParameter="https://toastielab.dev/EllieBotDevs/EllieHub/issues/new"
Command="{Binding OpenUrl}">
<StackPanel Orientation="Horizontal">
<TextBlock Text="Report a bug " />
<Image Classes="icon-url"
Source="{DynamicResource UrlIcon}" />
</StackPanel>
</Button>
</StackPanel>
<TextBlock Text="© 2025 Toastie_t0ast"
<TextBlock Text="© 2023 Toastie"
FontSize="11"
HorizontalAlignment="Center"
Margin="0 30 0 0"/>
<HyperlinkButton FontSize="11"
HorizontalAlignment="Center"
Content="GNU General Public License Version 3"
CommandParameter="https://toastielab.dev/EllieBotDevs/EllieHub/src/branch/main/LICENSE"
<StackPanel Orientation="Horizontal"
HorizontalAlignment="Center">
<TextBlock Text="License: "
FontSize="11" />
<Button Classes="link-11"
FontSize="11"
Content="Apache License Version 2"
CommandParameter="https://toastielab.dev/ToastieSharp/EllieHub/src/branch/main/LICENSE"
Command="{Binding OpenUrl}" />
</StackPanel>
</StackPanel>
</Window>

View file

@ -1,7 +1,7 @@
using Avalonia.ReactiveUI;
using EllieHub.Features.AppConfig.ViewModels;
using EllieHub.ViewModels.Windows;
namespace EllieHub.Features.AppConfig.Views.Windows;
namespace EllieHub.Views.Windows;
/// <summary>
/// Represents the about me dialog window.

View file

@ -2,13 +2,13 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="using:EllieHub.Features.AppWindow.ViewModels"
xmlns:vm="using:EllieHub.ViewModels.Windows"
xmlns:const="using:EllieHub.Common"
xmlns:dd="using:EllieHub.Avalonia.DesignData.Windows"
xmlns:dd="using:EllieHub.DesignData.Windows"
mc:Ignorable="d"
d:DesignWidth="{x:Static const:WindowConstants.MinWindowWidth}"
d:DesignHeight="{x:Static const:WindowConstants.MinWindowHeight}"
x:Class="EllieHub.Features.AppWindow.Views.Windows.AppView"
x:Class="EllieHub.Views.Windows.AppView"
x:DataType="vm:AppViewModel"
Icon="{DynamicResource EllieHubIcon}"
WindowStartupLocation="CenterScreen"

Some files were not shown because too many files have changed in this diff Show more